dsh-budget 0.1.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/CHANGELOG.md +26 -0
- package/LICENSE +201 -0
- package/README.es.md +140 -0
- package/README.hi.md +140 -0
- package/README.md +140 -0
- package/README.pt.md +140 -0
- package/README.zh.md +140 -0
- package/SECURITY.md +37 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/cordis.patch.yml +73 -0
- package/lib/client.js +4893 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1225 -0
- package/lib/typert.host.js +26 -0
- package/lib/types/aggregate/usage.d.ts +112 -0
- package/lib/types/aggregate/usage.d.ts.map +1 -0
- package/lib/types/client/BudgetTab.d.ts +18 -0
- package/lib/types/client/BudgetTab.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +42 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/present.d.ts +19 -0
- package/lib/types/client/present.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +270 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +12 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/command.d.ts +51 -0
- package/lib/types/command.d.ts.map +1 -0
- package/lib/types/config.d.ts +132 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/estimate/carbon.d.ts +128 -0
- package/lib/types/estimate/carbon.d.ts.map +1 -0
- package/lib/types/estimate/cost.d.ts +71 -0
- package/lib/types/estimate/cost.d.ts.map +1 -0
- package/lib/types/estimate/latency-stats.d.ts +111 -0
- package/lib/types/estimate/latency-stats.d.ts.map +1 -0
- package/lib/types/estimate/models.d.ts +29 -0
- package/lib/types/estimate/models.d.ts.map +1 -0
- package/lib/types/estimate/prices.d.ts +85 -0
- package/lib/types/estimate/prices.d.ts.map +1 -0
- package/lib/types/estimate/sanitize.d.ts +56 -0
- package/lib/types/estimate/sanitize.d.ts.map +1 -0
- package/lib/types/events.d.ts +57 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/governance.d.ts +67 -0
- package/lib/types/governance.d.ts.map +1 -0
- package/lib/types/index.d.ts +50 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/service.d.ts +67 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +258 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +629 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/lib/wire-DVO8yw7L.js +4219 -0
- package/package.json +164 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
import { n as BUDGET_SETTINGS_SCHEMA } from "./wire-DVO8yw7L.js";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
|
+
//#region src/config.ts
|
|
5
|
+
/**
|
|
6
|
+
* Config schema and resolution for `dsh-budget`. Every tunable is a validated
|
|
7
|
+
* {@link Config} field changeable from cordis.yml; `resolveConfig` re-judges
|
|
8
|
+
* every default and bound so programmatic construction that bypasses
|
|
9
|
+
* Schemastery normalization still fails loud (the explicit-resolve contract).
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-budget/config
|
|
12
|
+
*/
|
|
13
|
+
/** Known electricity region keys (upstream carbon table). */
|
|
14
|
+
const REGIONS = [
|
|
15
|
+
"global",
|
|
16
|
+
"us",
|
|
17
|
+
"eu",
|
|
18
|
+
"china",
|
|
19
|
+
"india",
|
|
20
|
+
"uk",
|
|
21
|
+
"france",
|
|
22
|
+
"iceland"
|
|
23
|
+
];
|
|
24
|
+
/** Schemastery schema: the loader validates and fills defaults before `apply`. */
|
|
25
|
+
const Config = z.object({
|
|
26
|
+
prices: z.dict(z.object({
|
|
27
|
+
input: z.number().min(0).required(),
|
|
28
|
+
output: z.number().min(0).required(),
|
|
29
|
+
cacheRead: z.number().min(0),
|
|
30
|
+
cacheWrite: z.number().min(0)
|
|
31
|
+
})).default({}),
|
|
32
|
+
defaultPrice: z.object({
|
|
33
|
+
input: z.number().min(0).default(1),
|
|
34
|
+
output: z.number().min(0).default(3),
|
|
35
|
+
cacheRead: z.number().min(0).default(1),
|
|
36
|
+
cacheWrite: z.number().min(0).default(1)
|
|
37
|
+
}).default({
|
|
38
|
+
input: 1,
|
|
39
|
+
output: 3,
|
|
40
|
+
cacheRead: 1,
|
|
41
|
+
cacheWrite: 1
|
|
42
|
+
}),
|
|
43
|
+
budgets: z.object({
|
|
44
|
+
session: z.number().min(0),
|
|
45
|
+
daily: z.number().min(0),
|
|
46
|
+
monthly: z.number().min(0)
|
|
47
|
+
}).default({
|
|
48
|
+
session: 10,
|
|
49
|
+
daily: 50,
|
|
50
|
+
monthly: 500
|
|
51
|
+
}),
|
|
52
|
+
warnRatio: z.number().min(0).max(1).default(.8),
|
|
53
|
+
overLimit: z.union([
|
|
54
|
+
"alert",
|
|
55
|
+
"block",
|
|
56
|
+
"degrade"
|
|
57
|
+
]).default("alert"),
|
|
58
|
+
degradation: z.dict(z.string()).default({}),
|
|
59
|
+
webhookUrl: z.string().default(""),
|
|
60
|
+
webhookTimeoutMs: z.number().min(100).max(12e4).default(5e3),
|
|
61
|
+
alertsEnabled: z.boolean().default(true),
|
|
62
|
+
alertCooldownMs: z.number().min(0).default(36e5),
|
|
63
|
+
desktopNotifications: z.boolean().default(false),
|
|
64
|
+
refreshIntervalMs: z.number().min(1e3).max(3e5).default(5e3),
|
|
65
|
+
carbon: z.object({
|
|
66
|
+
enabled: z.boolean().default(true),
|
|
67
|
+
region: z.union([...REGIONS]).default("global"),
|
|
68
|
+
pue: z.number().min(1).max(3).default(1.58),
|
|
69
|
+
energyKwhPerToken: z.number().min(0).default(7e-6)
|
|
70
|
+
}).default({
|
|
71
|
+
enabled: true,
|
|
72
|
+
region: "global",
|
|
73
|
+
pue: 1.58,
|
|
74
|
+
energyKwhPerToken: 7e-6
|
|
75
|
+
}),
|
|
76
|
+
latency: z.object({
|
|
77
|
+
enabled: z.boolean().default(true),
|
|
78
|
+
windowSize: z.number().min(1).max(1e4).default(200)
|
|
79
|
+
}).default({
|
|
80
|
+
enabled: true,
|
|
81
|
+
windowSize: 200
|
|
82
|
+
}),
|
|
83
|
+
currency: z.object({
|
|
84
|
+
code: z.string().default("USD"),
|
|
85
|
+
rate: z.number().min(1e-6).default(1),
|
|
86
|
+
decimals: z.number().min(0).max(6).default(2)
|
|
87
|
+
}).default({
|
|
88
|
+
code: "USD",
|
|
89
|
+
rate: 1,
|
|
90
|
+
decimals: 2
|
|
91
|
+
}),
|
|
92
|
+
outputLanguage: z.union(["en", "zh"]).default("en"),
|
|
93
|
+
historyDays: z.number().min(1).max(365).default(30)
|
|
94
|
+
});
|
|
95
|
+
/** Throw the standard fail-loud config error for one invalid field. */
|
|
96
|
+
function invalid(field, detail) {
|
|
97
|
+
throw new Error(`dsh-budget: config.${field} ${detail}`);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve raw config to the runtime policy, re-validating defaults and bounds.
|
|
101
|
+
*
|
|
102
|
+
* @param config - raw loader config; `undefined` for a bare row.
|
|
103
|
+
* @returns the frozen resolved config.
|
|
104
|
+
*/
|
|
105
|
+
function resolveConfig(config) {
|
|
106
|
+
const prices = { ...config?.prices ?? {} };
|
|
107
|
+
for (const [model, entry] of Object.entries(prices)) {
|
|
108
|
+
if (!Number.isFinite(entry.input) || entry.input < 0) invalid(`prices.${model}.input`, "must be a non-negative number");
|
|
109
|
+
if (!Number.isFinite(entry.output) || entry.output < 0) invalid(`prices.${model}.output`, "must be a non-negative number");
|
|
110
|
+
}
|
|
111
|
+
const defaultPrice = config?.defaultPrice ?? {
|
|
112
|
+
input: 1,
|
|
113
|
+
output: 3
|
|
114
|
+
};
|
|
115
|
+
if (!Number.isFinite(defaultPrice.input) || defaultPrice.input < 0) invalid("defaultPrice.input", "must be a non-negative number");
|
|
116
|
+
if (!Number.isFinite(defaultPrice.output) || defaultPrice.output < 0) invalid("defaultPrice.output", "must be a non-negative number");
|
|
117
|
+
const budgets = { ...config?.budgets ?? {
|
|
118
|
+
session: 10,
|
|
119
|
+
daily: 50,
|
|
120
|
+
monthly: 500
|
|
121
|
+
} };
|
|
122
|
+
for (const [scope, cap] of Object.entries(budgets)) if (cap !== void 0 && (!Number.isFinite(cap) || cap < 0)) invalid(`budgets.${scope}`, "must be a non-negative number");
|
|
123
|
+
const warnRatio = config?.warnRatio ?? .8;
|
|
124
|
+
if (!Number.isFinite(warnRatio) || warnRatio < 0 || warnRatio > 1) invalid("warnRatio", "must be a number in [0, 1]");
|
|
125
|
+
const overLimit = config?.overLimit ?? "alert";
|
|
126
|
+
if (overLimit !== "alert" && overLimit !== "block" && overLimit !== "degrade") invalid("overLimit", "must be alert, block, or degrade");
|
|
127
|
+
const degradation = { ...config?.degradation ?? {} };
|
|
128
|
+
const rawWebhook = config?.webhookUrl ?? "";
|
|
129
|
+
let webhookUrl;
|
|
130
|
+
if (rawWebhook.trim() !== "") try {
|
|
131
|
+
const parsed = new URL(rawWebhook.trim());
|
|
132
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("scheme");
|
|
133
|
+
webhookUrl = parsed.href;
|
|
134
|
+
} catch {
|
|
135
|
+
invalid("webhookUrl", "must be a valid http(s) URL");
|
|
136
|
+
}
|
|
137
|
+
const webhookTimeoutMs = config?.webhookTimeoutMs ?? 5e3;
|
|
138
|
+
if (!Number.isFinite(webhookTimeoutMs) || webhookTimeoutMs < 100 || webhookTimeoutMs > 12e4) invalid("webhookTimeoutMs", "must be a finite number in [100, 120000]");
|
|
139
|
+
const alertsEnabled = config?.alertsEnabled ?? true;
|
|
140
|
+
if (typeof alertsEnabled !== "boolean") invalid("alertsEnabled", "must be a boolean");
|
|
141
|
+
const alertCooldownMs = config?.alertCooldownMs ?? 36e5;
|
|
142
|
+
if (!Number.isFinite(alertCooldownMs) || alertCooldownMs < 0) invalid("alertCooldownMs", "must be a non-negative number");
|
|
143
|
+
const desktopNotifications = config?.desktopNotifications ?? false;
|
|
144
|
+
const refreshIntervalMs = config?.refreshIntervalMs ?? 5e3;
|
|
145
|
+
if (!Number.isFinite(refreshIntervalMs) || refreshIntervalMs < 1e3 || refreshIntervalMs > 3e5) invalid("refreshIntervalMs", "must be a finite number in [1000, 300000]");
|
|
146
|
+
const carbonEnabled = config?.carbon?.enabled ?? true;
|
|
147
|
+
const carbonRegion = config?.carbon?.region ?? "global";
|
|
148
|
+
if (!REGIONS.includes(carbonRegion)) invalid("carbon.region", `must be one of ${REGIONS.join(", ")}`);
|
|
149
|
+
const carbonPue = config?.carbon?.pue ?? 1.58;
|
|
150
|
+
if (!Number.isFinite(carbonPue) || carbonPue < 1 || carbonPue > 3) invalid("carbon.pue", "must be a finite number in [1, 3]");
|
|
151
|
+
const energyKwhPerToken = config?.carbon?.energyKwhPerToken ?? 7e-6;
|
|
152
|
+
if (!Number.isFinite(energyKwhPerToken) || energyKwhPerToken < 0) invalid("carbon.energyKwhPerToken", "must be a non-negative number");
|
|
153
|
+
const latencyEnabled = config?.latency?.enabled ?? true;
|
|
154
|
+
const windowSize = config?.latency?.windowSize ?? 200;
|
|
155
|
+
if (!Number.isInteger(windowSize) || windowSize < 1 || windowSize > 1e4) invalid("latency.windowSize", "must be an integer in [1, 10000]");
|
|
156
|
+
const currencyCode = (config?.currency?.code ?? "USD").toUpperCase();
|
|
157
|
+
if (!/^[A-Z]{3}$/u.test(currencyCode)) invalid("currency.code", "must be a 3-letter code");
|
|
158
|
+
const currencyRate = config?.currency?.rate ?? 1;
|
|
159
|
+
if (!Number.isFinite(currencyRate) || currencyRate <= 0) invalid("currency.rate", "must be a positive number");
|
|
160
|
+
const currencyDecimals = config?.currency?.decimals ?? 2;
|
|
161
|
+
if (!Number.isInteger(currencyDecimals) || currencyDecimals < 0 || currencyDecimals > 6) invalid("currency.decimals", "must be an integer in [0, 6]");
|
|
162
|
+
const outputLanguage = config?.outputLanguage ?? "en";
|
|
163
|
+
if (outputLanguage !== "en" && outputLanguage !== "zh") invalid("outputLanguage", "must be en or zh");
|
|
164
|
+
const historyDays = config?.historyDays ?? 30;
|
|
165
|
+
if (!Number.isInteger(historyDays) || historyDays < 1 || historyDays > 365) invalid("historyDays", "must be an integer in [1, 365]");
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
prices: Object.freeze(prices),
|
|
168
|
+
defaultPrice: Object.freeze(defaultPrice),
|
|
169
|
+
budgets: Object.freeze(budgets),
|
|
170
|
+
warnRatio,
|
|
171
|
+
overLimit,
|
|
172
|
+
degradation: Object.freeze(degradation),
|
|
173
|
+
webhookUrl,
|
|
174
|
+
webhookTimeoutMs,
|
|
175
|
+
alertsEnabled,
|
|
176
|
+
alertCooldownMs,
|
|
177
|
+
desktopNotifications,
|
|
178
|
+
refreshIntervalMs,
|
|
179
|
+
carbon: Object.freeze({
|
|
180
|
+
enabled: carbonEnabled,
|
|
181
|
+
region: carbonRegion,
|
|
182
|
+
pue: carbonPue,
|
|
183
|
+
energyKwhPerToken
|
|
184
|
+
}),
|
|
185
|
+
latency: Object.freeze({
|
|
186
|
+
enabled: latencyEnabled,
|
|
187
|
+
windowSize
|
|
188
|
+
}),
|
|
189
|
+
currency: Object.freeze({
|
|
190
|
+
code: currencyCode,
|
|
191
|
+
rate: currencyRate,
|
|
192
|
+
decimals: currencyDecimals
|
|
193
|
+
}),
|
|
194
|
+
outputLanguage,
|
|
195
|
+
historyDays
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/estimate/prices.ts
|
|
200
|
+
/**
|
|
201
|
+
* Fixed CNY→USD rate used when converting the upstream CNY-per-1k table into
|
|
202
|
+
* USD-per-1M entries, captured at porting time. It is a conversion constant
|
|
203
|
+
* of the shipped DATA, not a runtime exchange rate; override any converted
|
|
204
|
+
* entry via `config.prices` when vendor USD pricing is available.
|
|
205
|
+
*/
|
|
206
|
+
const UPSTREAM_CNY_PER_USD = 7.2;
|
|
207
|
+
/**
|
|
208
|
+
* Convert an upstream CNY-per-1k price into USD per 1M tokens.
|
|
209
|
+
*
|
|
210
|
+
* @param cnyPer1k - price in CNY per 1000 tokens.
|
|
211
|
+
* @returns the price in USD per 1,000,000 tokens.
|
|
212
|
+
*/
|
|
213
|
+
function cnyPer1kToUsdPer1m(cnyPer1k) {
|
|
214
|
+
return cnyPer1k * 1e3 / UPSTREAM_CNY_PER_USD;
|
|
215
|
+
}
|
|
216
|
+
/** The built-in price table (USD per 1M tokens). */
|
|
217
|
+
const BUILTIN_PRICES = Object.freeze({
|
|
218
|
+
"deepseek-chat": {
|
|
219
|
+
input: .27,
|
|
220
|
+
cacheRead: .027,
|
|
221
|
+
cacheWrite: .27,
|
|
222
|
+
output: 1.1,
|
|
223
|
+
source: "vendor"
|
|
224
|
+
},
|
|
225
|
+
"deepseek-reasoner": {
|
|
226
|
+
input: .55,
|
|
227
|
+
cacheRead: .055,
|
|
228
|
+
cacheWrite: .55,
|
|
229
|
+
output: 2.19,
|
|
230
|
+
source: "vendor"
|
|
231
|
+
},
|
|
232
|
+
"gpt-4o": {
|
|
233
|
+
input: 2.5,
|
|
234
|
+
cacheRead: 1.25,
|
|
235
|
+
output: 10,
|
|
236
|
+
source: "vendor"
|
|
237
|
+
},
|
|
238
|
+
"gpt-4o-mini": {
|
|
239
|
+
input: .15,
|
|
240
|
+
cacheRead: .075,
|
|
241
|
+
output: .6,
|
|
242
|
+
source: "vendor"
|
|
243
|
+
},
|
|
244
|
+
"gpt-4.1": {
|
|
245
|
+
input: 2,
|
|
246
|
+
cacheRead: .5,
|
|
247
|
+
output: 8,
|
|
248
|
+
source: "vendor"
|
|
249
|
+
},
|
|
250
|
+
"gpt-4.1-mini": {
|
|
251
|
+
input: .4,
|
|
252
|
+
cacheRead: .1,
|
|
253
|
+
output: 1.6,
|
|
254
|
+
source: "vendor"
|
|
255
|
+
},
|
|
256
|
+
"claude-sonnet-4-5": {
|
|
257
|
+
input: 3,
|
|
258
|
+
output: 15,
|
|
259
|
+
source: "vendor"
|
|
260
|
+
},
|
|
261
|
+
"claude-haiku-4-5": {
|
|
262
|
+
input: 1,
|
|
263
|
+
output: 5,
|
|
264
|
+
source: "vendor"
|
|
265
|
+
},
|
|
266
|
+
"gemini-2.5-pro": {
|
|
267
|
+
input: 1.25,
|
|
268
|
+
output: 10,
|
|
269
|
+
source: "vendor"
|
|
270
|
+
},
|
|
271
|
+
"gemini-2.5-flash": {
|
|
272
|
+
input: .3,
|
|
273
|
+
output: 2.5,
|
|
274
|
+
source: "vendor"
|
|
275
|
+
},
|
|
276
|
+
"ernie-4.0": {
|
|
277
|
+
input: cnyPer1kToUsdPer1m(.12),
|
|
278
|
+
output: cnyPer1kToUsdPer1m(.12),
|
|
279
|
+
source: "upstream-cny"
|
|
280
|
+
},
|
|
281
|
+
"qwen-turbo": {
|
|
282
|
+
input: cnyPer1kToUsdPer1m(.008),
|
|
283
|
+
output: cnyPer1kToUsdPer1m(.008),
|
|
284
|
+
source: "upstream-cny"
|
|
285
|
+
},
|
|
286
|
+
"qwen-plus": {
|
|
287
|
+
input: cnyPer1kToUsdPer1m(.04),
|
|
288
|
+
output: cnyPer1kToUsdPer1m(.04),
|
|
289
|
+
source: "upstream-cny"
|
|
290
|
+
},
|
|
291
|
+
"glm-4": {
|
|
292
|
+
input: cnyPer1kToUsdPer1m(.1),
|
|
293
|
+
output: cnyPer1kToUsdPer1m(.1),
|
|
294
|
+
source: "upstream-cny"
|
|
295
|
+
},
|
|
296
|
+
"baichuan2-turbo": {
|
|
297
|
+
input: cnyPer1kToUsdPer1m(.008),
|
|
298
|
+
output: cnyPer1kToUsdPer1m(.008),
|
|
299
|
+
source: "upstream-cny"
|
|
300
|
+
},
|
|
301
|
+
"moonshot-v1-8k": {
|
|
302
|
+
input: cnyPer1kToUsdPer1m(.012),
|
|
303
|
+
output: cnyPer1kToUsdPer1m(.012),
|
|
304
|
+
source: "upstream-cny"
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* Merge the user table over the built-in table (per-model override).
|
|
309
|
+
*
|
|
310
|
+
* @param custom - `config.prices` entries.
|
|
311
|
+
* @returns the merged table; custom entries win per model id.
|
|
312
|
+
*/
|
|
313
|
+
function mergePrices(custom) {
|
|
314
|
+
return {
|
|
315
|
+
...BUILTIN_PRICES,
|
|
316
|
+
...custom
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Resolve the price for one exact route: `${provider}/${model}` first, then
|
|
321
|
+
* the bare model id, then the fallback.
|
|
322
|
+
*
|
|
323
|
+
* @param table - merged price table.
|
|
324
|
+
* @param fallback - price for models absent from the table.
|
|
325
|
+
* @param provider - registered provider route.
|
|
326
|
+
* @param model - model id.
|
|
327
|
+
* @returns the effective price entry (never undefined).
|
|
328
|
+
*/
|
|
329
|
+
function priceFor(table, fallback, provider, model) {
|
|
330
|
+
return table[`${provider}/${model}`] ?? table[model] ?? fallback;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Price one disjoint token usage record.
|
|
334
|
+
*
|
|
335
|
+
* @param price - effective price entry.
|
|
336
|
+
* @param inputTokens - uncached input tokens.
|
|
337
|
+
* @param outputTokens - output tokens.
|
|
338
|
+
* @param cacheReadTokens - cache-hit tokens (0 when absent).
|
|
339
|
+
* @param cacheWriteTokens - cache-miss tokens (0 when absent).
|
|
340
|
+
* @returns the USD cost breakdown.
|
|
341
|
+
*/
|
|
342
|
+
function estimateUsageCost(price, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens) {
|
|
343
|
+
const inputCost = inputTokens / 1e6 * price.input;
|
|
344
|
+
const outputCost = outputTokens / 1e6 * price.output;
|
|
345
|
+
const cacheReadCost = cacheReadTokens / 1e6 * (price.cacheRead ?? price.input);
|
|
346
|
+
const cacheWriteCost = cacheWriteTokens / 1e6 * (price.cacheWrite ?? price.input);
|
|
347
|
+
return {
|
|
348
|
+
inputCost,
|
|
349
|
+
outputCost,
|
|
350
|
+
cacheReadCost,
|
|
351
|
+
cacheWriteCost,
|
|
352
|
+
totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
Object.freeze({
|
|
356
|
+
"A100": {
|
|
357
|
+
tdp: 400,
|
|
358
|
+
name: "NVIDIA A100",
|
|
359
|
+
category: "datacenter"
|
|
360
|
+
},
|
|
361
|
+
"A100-80GB": {
|
|
362
|
+
tdp: 400,
|
|
363
|
+
name: "NVIDIA A100 80GB",
|
|
364
|
+
category: "datacenter"
|
|
365
|
+
},
|
|
366
|
+
"H100": {
|
|
367
|
+
tdp: 700,
|
|
368
|
+
name: "NVIDIA H100",
|
|
369
|
+
category: "datacenter"
|
|
370
|
+
},
|
|
371
|
+
"H100-80GB": {
|
|
372
|
+
tdp: 700,
|
|
373
|
+
name: "NVIDIA H100 80GB",
|
|
374
|
+
category: "datacenter"
|
|
375
|
+
},
|
|
376
|
+
"V100": {
|
|
377
|
+
tdp: 300,
|
|
378
|
+
name: "NVIDIA V100",
|
|
379
|
+
category: "datacenter"
|
|
380
|
+
},
|
|
381
|
+
"A40": {
|
|
382
|
+
tdp: 300,
|
|
383
|
+
name: "NVIDIA A40",
|
|
384
|
+
category: "datacenter"
|
|
385
|
+
},
|
|
386
|
+
"A30": {
|
|
387
|
+
tdp: 165,
|
|
388
|
+
name: "NVIDIA A30",
|
|
389
|
+
category: "datacenter"
|
|
390
|
+
},
|
|
391
|
+
"A10": {
|
|
392
|
+
tdp: 150,
|
|
393
|
+
name: "NVIDIA A10",
|
|
394
|
+
category: "datacenter"
|
|
395
|
+
},
|
|
396
|
+
"RTX-4090": {
|
|
397
|
+
tdp: 450,
|
|
398
|
+
name: "NVIDIA RTX 4090",
|
|
399
|
+
category: "consumer"
|
|
400
|
+
},
|
|
401
|
+
"RTX-4080": {
|
|
402
|
+
tdp: 320,
|
|
403
|
+
name: "NVIDIA RTX 4080",
|
|
404
|
+
category: "consumer"
|
|
405
|
+
},
|
|
406
|
+
"RTX-3090": {
|
|
407
|
+
tdp: 350,
|
|
408
|
+
name: "NVIDIA RTX 3090",
|
|
409
|
+
category: "consumer"
|
|
410
|
+
},
|
|
411
|
+
"RTX-3080": {
|
|
412
|
+
tdp: 320,
|
|
413
|
+
name: "NVIDIA RTX 3080",
|
|
414
|
+
category: "consumer"
|
|
415
|
+
},
|
|
416
|
+
"MI250X": {
|
|
417
|
+
tdp: 560,
|
|
418
|
+
name: "AMD MI250X",
|
|
419
|
+
category: "datacenter"
|
|
420
|
+
},
|
|
421
|
+
"MI210": {
|
|
422
|
+
tdp: 300,
|
|
423
|
+
name: "AMD MI210",
|
|
424
|
+
category: "datacenter"
|
|
425
|
+
},
|
|
426
|
+
"MI100": {
|
|
427
|
+
tdp: 300,
|
|
428
|
+
name: "AMD MI100",
|
|
429
|
+
category: "datacenter"
|
|
430
|
+
},
|
|
431
|
+
"TPU-v4": {
|
|
432
|
+
tdp: 450,
|
|
433
|
+
name: "Google TPU v4",
|
|
434
|
+
category: "tpu"
|
|
435
|
+
},
|
|
436
|
+
"TPU-v3": {
|
|
437
|
+
tdp: 450,
|
|
438
|
+
name: "Google TPU v3",
|
|
439
|
+
category: "tpu"
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
/** Regional grid carbon intensity in kg CO2e per kWh, verbatim from upstream. */
|
|
443
|
+
const CARBON_INTENSITY = Object.freeze({
|
|
444
|
+
global: .475,
|
|
445
|
+
us: .386,
|
|
446
|
+
eu: .276,
|
|
447
|
+
china: .555,
|
|
448
|
+
india: .708,
|
|
449
|
+
uk: .233,
|
|
450
|
+
france: .056,
|
|
451
|
+
iceland: .01
|
|
452
|
+
});
|
|
453
|
+
Object.freeze({
|
|
454
|
+
car_year: {
|
|
455
|
+
name: "燃油车行驶一年",
|
|
456
|
+
co2Kg: 4600,
|
|
457
|
+
emoji: "🚗"
|
|
458
|
+
},
|
|
459
|
+
flight_nyc_london: {
|
|
460
|
+
name: "纽约-伦敦往返航班",
|
|
461
|
+
co2Kg: 1100,
|
|
462
|
+
emoji: "✈️"
|
|
463
|
+
},
|
|
464
|
+
tree_year: {
|
|
465
|
+
name: "树木一年吸收的CO2",
|
|
466
|
+
co2Kg: 21,
|
|
467
|
+
emoji: "🌲"
|
|
468
|
+
},
|
|
469
|
+
smartphone_charge: {
|
|
470
|
+
name: "智能手机充电",
|
|
471
|
+
co2Kg: .008,
|
|
472
|
+
emoji: "📱"
|
|
473
|
+
},
|
|
474
|
+
home_electricity_month: {
|
|
475
|
+
name: "家庭一个月用电",
|
|
476
|
+
co2Kg: 400,
|
|
477
|
+
emoji: "🏠"
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
/**
|
|
481
|
+
* Estimate the carbon footprint of a token volume (the plugin's token→carbon
|
|
482
|
+
* bridge): energy = tokens × kWh/token, total = energy × PUE,
|
|
483
|
+
* CO2 = total × regional intensity.
|
|
484
|
+
*
|
|
485
|
+
* @param tokens - total tokens processed (all buckets).
|
|
486
|
+
* @param energyKwhPerToken - IT energy per token in kWh.
|
|
487
|
+
* @param pue - power usage effectiveness.
|
|
488
|
+
* @param region - electricity region key.
|
|
489
|
+
* @returns the estimation result.
|
|
490
|
+
* @throws on an unknown region.
|
|
491
|
+
*/
|
|
492
|
+
function tokenCarbon(tokens, energyKwhPerToken, pue, region) {
|
|
493
|
+
const carbonIntensity = CARBON_INTENSITY[region];
|
|
494
|
+
if (carbonIntensity === void 0) throw new Error(`Unknown region: ${region}`);
|
|
495
|
+
const energyKwh = tokens * energyKwhPerToken;
|
|
496
|
+
const totalEnergyKwh = energyKwh * pue;
|
|
497
|
+
return {
|
|
498
|
+
energyKwh,
|
|
499
|
+
totalEnergyKwh,
|
|
500
|
+
co2Kg: totalEnergyKwh * carbonIntensity,
|
|
501
|
+
carbonIntensity
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/aggregate/usage.ts
|
|
506
|
+
/** Empty usage record. */
|
|
507
|
+
function emptyUsage() {
|
|
508
|
+
return {
|
|
509
|
+
inputTokens: 0,
|
|
510
|
+
outputTokens: 0,
|
|
511
|
+
cacheReadTokens: 0,
|
|
512
|
+
cacheWriteTokens: 0,
|
|
513
|
+
costUsd: 0,
|
|
514
|
+
carbonKg: 0
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** UTC day key, e.g. `2026-08-16`. */
|
|
518
|
+
function dayKey(timestamp) {
|
|
519
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
520
|
+
}
|
|
521
|
+
/** UTC month key, e.g. `2026-08`. */
|
|
522
|
+
function monthKey(timestamp) {
|
|
523
|
+
return new Date(timestamp).toISOString().slice(0, 7);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The runtime aggregator. All methods are synchronous; the caller invokes
|
|
527
|
+
* them from the session-event listener (the event hot path).
|
|
528
|
+
*/
|
|
529
|
+
var BudgetAggregator = class {
|
|
530
|
+
config;
|
|
531
|
+
now;
|
|
532
|
+
state;
|
|
533
|
+
table;
|
|
534
|
+
/** @param config - resolved plugin config. @param now - clock (defaults to Date.now). */
|
|
535
|
+
constructor(config, now = Date.now) {
|
|
536
|
+
this.config = config;
|
|
537
|
+
this.now = now;
|
|
538
|
+
this.table = mergePrices(config.prices);
|
|
539
|
+
this.state = {
|
|
540
|
+
sessions: /* @__PURE__ */ new Map(),
|
|
541
|
+
days: /* @__PURE__ */ new Map(),
|
|
542
|
+
months: /* @__PURE__ */ new Map(),
|
|
543
|
+
latency: /* @__PURE__ */ new Map(),
|
|
544
|
+
blockedScopes: /* @__PURE__ */ new Set(),
|
|
545
|
+
alerts: [],
|
|
546
|
+
lastAlertAt: /* @__PURE__ */ new Map(),
|
|
547
|
+
provider: "",
|
|
548
|
+
model: ""
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/** Update the current provider/model attribution from a request header. */
|
|
552
|
+
setAttribution(provider, model) {
|
|
553
|
+
this.state.provider = provider;
|
|
554
|
+
this.state.model = model;
|
|
555
|
+
}
|
|
556
|
+
/** The current provider/model attribution (latest request header). */
|
|
557
|
+
attribution() {
|
|
558
|
+
return {
|
|
559
|
+
provider: this.state.provider,
|
|
560
|
+
model: this.state.model
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Record one usage payload against the current attribution. Unknown or
|
|
565
|
+
* zero usage is a no-op.
|
|
566
|
+
*
|
|
567
|
+
* @param sessionId - owning session id (string form).
|
|
568
|
+
* @param usage - the token usage payload.
|
|
569
|
+
*/
|
|
570
|
+
recordUsage(sessionId, usage) {
|
|
571
|
+
const input = usage.inputTokens;
|
|
572
|
+
const output = usage.outputTokens;
|
|
573
|
+
const cacheRead = usage.cacheReadTokens ?? 0;
|
|
574
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
575
|
+
if (input + output + cacheRead + cacheWrite <= 0) return;
|
|
576
|
+
const cost = estimateUsageCost(priceFor(this.table, this.config.defaultPrice, this.state.provider, this.state.model), input, output, cacheRead, cacheWrite);
|
|
577
|
+
const tokens = input + output + cacheRead + cacheWrite;
|
|
578
|
+
const carbon = this.config.carbon.enabled ? tokenCarbon(tokens, this.config.carbon.energyKwhPerToken, this.config.carbon.pue, this.config.carbon.region).co2Kg : 0;
|
|
579
|
+
const add = (target) => {
|
|
580
|
+
target.inputTokens += input;
|
|
581
|
+
target.outputTokens += output;
|
|
582
|
+
target.cacheReadTokens += cacheRead;
|
|
583
|
+
target.cacheWriteTokens += cacheWrite;
|
|
584
|
+
target.costUsd += cost.totalCost;
|
|
585
|
+
target.carbonKg += carbon;
|
|
586
|
+
};
|
|
587
|
+
const session = this.state.sessions.get(sessionId) ?? emptyUsage();
|
|
588
|
+
add(session);
|
|
589
|
+
this.state.sessions.set(sessionId, session);
|
|
590
|
+
const at = this.now();
|
|
591
|
+
const day = this.state.days.get(dayKey(at)) ?? {
|
|
592
|
+
total: emptyUsage(),
|
|
593
|
+
models: /* @__PURE__ */ new Map()
|
|
594
|
+
};
|
|
595
|
+
add(day.total);
|
|
596
|
+
this.addModel(day.models, input, output, cacheRead, cacheWrite, cost.totalCost, carbon);
|
|
597
|
+
this.state.days.set(dayKey(at), day);
|
|
598
|
+
const month = this.state.months.get(monthKey(at)) ?? {
|
|
599
|
+
total: emptyUsage(),
|
|
600
|
+
models: /* @__PURE__ */ new Map()
|
|
601
|
+
};
|
|
602
|
+
add(month.total);
|
|
603
|
+
this.addModel(month.models, input, output, cacheRead, cacheWrite, cost.totalCost, carbon);
|
|
604
|
+
this.state.months.set(monthKey(at), month);
|
|
605
|
+
}
|
|
606
|
+
/** Record one measured call duration for one model. */
|
|
607
|
+
recordLatency(model, durationMs) {
|
|
608
|
+
if (!this.config.latency.enabled || !Number.isFinite(durationMs) || durationMs < 0) return;
|
|
609
|
+
const window = this.state.latency.get(model) ?? [];
|
|
610
|
+
window.push(durationMs);
|
|
611
|
+
if (window.length > this.config.latency.windowSize) window.shift();
|
|
612
|
+
this.state.latency.set(model, window);
|
|
613
|
+
}
|
|
614
|
+
/** Merge one usage record into a per-model map. */
|
|
615
|
+
addModel(models, input, output, cacheRead, cacheWrite, costUsd, carbonKg) {
|
|
616
|
+
const model = this.state.model || "unknown";
|
|
617
|
+
const entry = models.get(model) ?? {
|
|
618
|
+
provider: this.state.provider || "unknown",
|
|
619
|
+
model,
|
|
620
|
+
inputTokens: 0,
|
|
621
|
+
outputTokens: 0,
|
|
622
|
+
cacheReadTokens: 0,
|
|
623
|
+
cacheWriteTokens: 0,
|
|
624
|
+
costUsd: 0,
|
|
625
|
+
carbonKg: 0,
|
|
626
|
+
latencyMs: []
|
|
627
|
+
};
|
|
628
|
+
entry.inputTokens += input;
|
|
629
|
+
entry.outputTokens += output;
|
|
630
|
+
entry.cacheReadTokens += cacheRead;
|
|
631
|
+
entry.cacheWriteTokens += cacheWrite;
|
|
632
|
+
entry.costUsd += costUsd;
|
|
633
|
+
entry.carbonKg += carbonKg;
|
|
634
|
+
models.set(model, entry);
|
|
635
|
+
}
|
|
636
|
+
/** Read one session's usage (empty when unknown). */
|
|
637
|
+
sessionUsage(sessionId) {
|
|
638
|
+
return this.state.sessions.get(sessionId) ?? emptyUsage();
|
|
639
|
+
}
|
|
640
|
+
/** Read today's usage. */
|
|
641
|
+
todayUsage() {
|
|
642
|
+
return this.state.days.get(dayKey(this.now()))?.total ?? emptyUsage();
|
|
643
|
+
}
|
|
644
|
+
/** Read this month's usage. */
|
|
645
|
+
monthUsage() {
|
|
646
|
+
return this.state.months.get(monthKey(this.now()))?.total ?? emptyUsage();
|
|
647
|
+
}
|
|
648
|
+
/** Per-model usage (today) with latency percentiles, sorted by cost. */
|
|
649
|
+
modelUsage() {
|
|
650
|
+
const list = [...this.state.days.get(dayKey(this.now()))?.models.values() ?? []];
|
|
651
|
+
for (const entry of list) entry.latencyMs = [...this.state.latency.get(entry.model) ?? []];
|
|
652
|
+
list.sort((a, b) => b.costUsd - a.costUsd);
|
|
653
|
+
return list;
|
|
654
|
+
}
|
|
655
|
+
/** Record one threshold alert (bounded to the most recent 50). */
|
|
656
|
+
recordAlert(alert) {
|
|
657
|
+
this.state.alerts.unshift(alert);
|
|
658
|
+
if (this.state.alerts.length > 50) this.state.alerts.length = 50;
|
|
659
|
+
this.state.lastAlertAt.set(`${alert.scope}:${alert.kind}`, alert.at);
|
|
660
|
+
}
|
|
661
|
+
/** Milliseconds since the last alert of the same scope+kind (Infinity when none). */
|
|
662
|
+
lastAlertAge(scope, kind, at) {
|
|
663
|
+
const last = this.state.lastAlertAt.get(`${scope}:${kind}`);
|
|
664
|
+
return last === void 0 ? Number.POSITIVE_INFINITY : at - last;
|
|
665
|
+
}
|
|
666
|
+
/** The current blocked scopes (budget crosses that the user has not lifted). */
|
|
667
|
+
get blockedScopes() {
|
|
668
|
+
return [...this.state.blockedScopes];
|
|
669
|
+
}
|
|
670
|
+
/** Mark one scope blocked (over-limit with block/degrade policy). */
|
|
671
|
+
blockScope(scope) {
|
|
672
|
+
this.state.blockedScopes.add(scope);
|
|
673
|
+
}
|
|
674
|
+
/** Lift a block (user confirmation through /budget unblock or the panel). */
|
|
675
|
+
unblockScope(scope) {
|
|
676
|
+
this.state.blockedScopes.delete(scope);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The per-scope snapshot for one session (the panel and the command
|
|
680
|
+
* attribute usage to the session that owns the view).
|
|
681
|
+
*
|
|
682
|
+
* @param sessionId - owning session id.
|
|
683
|
+
* @returns the session-attributed snapshot.
|
|
684
|
+
*/
|
|
685
|
+
snapshotFor(sessionId) {
|
|
686
|
+
return {
|
|
687
|
+
session: this.sessionUsage(sessionId),
|
|
688
|
+
today: this.todayUsage(),
|
|
689
|
+
thisMonth: this.monthUsage(),
|
|
690
|
+
models: this.modelUsage(),
|
|
691
|
+
alerts: [...this.state.alerts],
|
|
692
|
+
blockedScopes: this.blockedScopes
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/estimate/sanitize.ts
|
|
698
|
+
/**
|
|
699
|
+
* Sanitization and formatting pure functions. Every display/log surface of
|
|
700
|
+
* dsh-budget goes through these: webhook URLs, alert text, token counts and
|
|
701
|
+
* money amounts. Never log or render a raw config or wire value.
|
|
702
|
+
*
|
|
703
|
+
* @module dsh-budget/estimate/sanitize
|
|
704
|
+
*/
|
|
705
|
+
/** Control characters plus zero-width and bidi-override code points. */
|
|
706
|
+
const CONTROL_OR_INVISIBLE = /[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/gu;
|
|
707
|
+
/**
|
|
708
|
+
* Make arbitrary text safe for one-line display and logs: strip control and
|
|
709
|
+
* invisible characters, collapse whitespace runs, truncate with an ellipsis.
|
|
710
|
+
*
|
|
711
|
+
* @param input - raw text (possibly hostile).
|
|
712
|
+
* @param maxLength - output length cap (default 200).
|
|
713
|
+
* @returns the sanitized text.
|
|
714
|
+
*/
|
|
715
|
+
function sanitizeText(input, maxLength = 200) {
|
|
716
|
+
const text = input.replace(CONTROL_OR_INVISIBLE, "").replace(/\s+/gu, " ").trim();
|
|
717
|
+
if (text.length <= maxLength) return text;
|
|
718
|
+
const cut = maxLength - 1;
|
|
719
|
+
return cut > 0 ? `${text.slice(0, cut)}…` : "";
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/governance.ts
|
|
723
|
+
/**
|
|
724
|
+
* Check every budget scope for one session after new usage landed.
|
|
725
|
+
*
|
|
726
|
+
* @param config - resolved plugin config.
|
|
727
|
+
* @param aggregator - the runtime aggregator.
|
|
728
|
+
* @param sessionId - owning session id.
|
|
729
|
+
* @param hooks - alert/block sinks.
|
|
730
|
+
* @param at - current timestamp (injected for tests).
|
|
731
|
+
* @returns the per-scope decisions, in scope order.
|
|
732
|
+
*/
|
|
733
|
+
function checkBudgets(config, aggregator, sessionId, hooks, at) {
|
|
734
|
+
const scopes = [
|
|
735
|
+
{
|
|
736
|
+
scope: "session",
|
|
737
|
+
used: aggregator.sessionUsage(sessionId),
|
|
738
|
+
cap: config.budgets.session
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
scope: "daily",
|
|
742
|
+
used: aggregator.todayUsage(),
|
|
743
|
+
cap: config.budgets.daily
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
scope: "monthly",
|
|
747
|
+
used: aggregator.monthUsage(),
|
|
748
|
+
cap: config.budgets.monthly
|
|
749
|
+
}
|
|
750
|
+
];
|
|
751
|
+
const decisions = [];
|
|
752
|
+
for (const { scope, used, cap } of scopes) {
|
|
753
|
+
if (cap === void 0 || cap <= 0) {
|
|
754
|
+
decisions.push({
|
|
755
|
+
scope,
|
|
756
|
+
capUsd: void 0,
|
|
757
|
+
usedUsd: used.costUsd,
|
|
758
|
+
ratio: 0,
|
|
759
|
+
state: "ok"
|
|
760
|
+
});
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const ratio = used.costUsd / cap;
|
|
764
|
+
const state = used.costUsd >= cap ? "over" : used.costUsd >= cap * config.warnRatio ? "warn" : "ok";
|
|
765
|
+
decisions.push({
|
|
766
|
+
scope,
|
|
767
|
+
capUsd: cap,
|
|
768
|
+
usedUsd: used.costUsd,
|
|
769
|
+
ratio,
|
|
770
|
+
state
|
|
771
|
+
});
|
|
772
|
+
if (state === "ok" || !config.alertsEnabled) continue;
|
|
773
|
+
if (aggregator.lastAlertAge(scope, state, at) < config.alertCooldownMs) continue;
|
|
774
|
+
aggregator.recordAlert({
|
|
775
|
+
scope,
|
|
776
|
+
kind: state,
|
|
777
|
+
at,
|
|
778
|
+
usedUsd: used.costUsd,
|
|
779
|
+
capUsd: cap
|
|
780
|
+
});
|
|
781
|
+
hooks.onAlert(decisions[decisions.length - 1]);
|
|
782
|
+
if (state === "over") {
|
|
783
|
+
if (config.overLimit === "block" || config.overLimit === "degrade") {
|
|
784
|
+
aggregator.blockScope(scope);
|
|
785
|
+
hooks.onBlock(scope, degradationFor(config, aggregator, scope));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return decisions;
|
|
790
|
+
}
|
|
791
|
+
/** The degradation pair for the current attribution, when configured. */
|
|
792
|
+
function degradationFor(config, aggregator, _scope) {
|
|
793
|
+
const model = aggregator.attribution().model;
|
|
794
|
+
if (model === "") return void 0;
|
|
795
|
+
const to = config.degradation[model];
|
|
796
|
+
if (to === void 0 || to === "") return void 0;
|
|
797
|
+
return {
|
|
798
|
+
from: model,
|
|
799
|
+
to
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/** Sanitize a webhook URL for logs: scheme + host only, credentials dropped. */
|
|
803
|
+
function webhookDisplay(url) {
|
|
804
|
+
try {
|
|
805
|
+
const parsed = new URL(url);
|
|
806
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
807
|
+
} catch {
|
|
808
|
+
return sanitizeText(url, 80);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Fire one threshold-alert webhook POST (JSON body). Failures are swallowed
|
|
813
|
+
* and returned — alerts must never crash the hot path. The URL itself is
|
|
814
|
+
* never logged with credentials attached.
|
|
815
|
+
*
|
|
816
|
+
* @param url - validated webhook URL.
|
|
817
|
+
* @param payload - JSON-serializable payload.
|
|
818
|
+
* @param timeoutMs - request timeout.
|
|
819
|
+
* @param logger - warn sink for failures.
|
|
820
|
+
* @returns the outcome (ok or the error message).
|
|
821
|
+
*/
|
|
822
|
+
async function sendWebhook(url, payload, timeoutMs, logger) {
|
|
823
|
+
try {
|
|
824
|
+
const controller = new AbortController();
|
|
825
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
826
|
+
try {
|
|
827
|
+
const response = await fetch(url, {
|
|
828
|
+
method: "POST",
|
|
829
|
+
headers: { "content-type": "application/json" },
|
|
830
|
+
body: JSON.stringify(payload),
|
|
831
|
+
signal: controller.signal
|
|
832
|
+
});
|
|
833
|
+
if (response.status < 200 || response.status >= 300) return {
|
|
834
|
+
ok: false,
|
|
835
|
+
error: `webhook answered HTTP ${response.status}`
|
|
836
|
+
};
|
|
837
|
+
return { ok: true };
|
|
838
|
+
} finally {
|
|
839
|
+
clearTimeout(timer);
|
|
840
|
+
}
|
|
841
|
+
} catch (error) {
|
|
842
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
843
|
+
logger.warn(`budget webhook alert failed (${webhookDisplay(url)}): ${message}`);
|
|
844
|
+
return {
|
|
845
|
+
ok: false,
|
|
846
|
+
error: message
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/events.ts
|
|
852
|
+
/** The alert audit event type. */
|
|
853
|
+
const ALERT_EVENT = "budget/alert";
|
|
854
|
+
/** The block audit event type. */
|
|
855
|
+
const BLOCK_EVENT = "budget/block";
|
|
856
|
+
//#endregion
|
|
857
|
+
//#region src/service.ts
|
|
858
|
+
/** Effective cap lookup: runtime override first, then the config. */
|
|
859
|
+
function effectiveCap(settings, config, scope) {
|
|
860
|
+
if (scope === "session") return settings.sessionCapUsd ?? config.budgets.session ?? null;
|
|
861
|
+
if (scope === "daily") return settings.dailyCapUsd ?? config.budgets.daily ?? null;
|
|
862
|
+
return settings.monthlyCapUsd ?? config.budgets.monthly ?? null;
|
|
863
|
+
}
|
|
864
|
+
/** One latency window's percentile summary. */
|
|
865
|
+
function latencySummary(samples) {
|
|
866
|
+
if (samples.length === 0) return {
|
|
867
|
+
min: null,
|
|
868
|
+
p50: null,
|
|
869
|
+
p95: null,
|
|
870
|
+
max: null,
|
|
871
|
+
samples: 0
|
|
872
|
+
};
|
|
873
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
874
|
+
const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? 0;
|
|
875
|
+
return {
|
|
876
|
+
min: sorted[0] ?? null,
|
|
877
|
+
p50: at(.5),
|
|
878
|
+
p95: at(.95),
|
|
879
|
+
max: sorted[sorted.length - 1] ?? null,
|
|
880
|
+
samples: sorted.length
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* The `budget` Typert Remote service. Constructed by `src/index.ts` with the
|
|
885
|
+
* shared aggregator, the resolved config, and the runtime settings object;
|
|
886
|
+
* mounted through `ctx.plugin`.
|
|
887
|
+
*/
|
|
888
|
+
var BudgetService = class extends TypertRemoteService {
|
|
889
|
+
bindings;
|
|
890
|
+
/** No service dependencies: pure status surface over injected bindings. */
|
|
891
|
+
static inject = [];
|
|
892
|
+
/**
|
|
893
|
+
* @param ctx - the mounting context.
|
|
894
|
+
* @param bindings - aggregator, resolved config, runtime settings, and the degraded-model probe.
|
|
895
|
+
*/
|
|
896
|
+
constructor(ctx, bindings) {
|
|
897
|
+
super(ctx, "budget");
|
|
898
|
+
this.bindings = bindings;
|
|
899
|
+
}
|
|
900
|
+
/** Assemble the wire snapshot for one session. */
|
|
901
|
+
status(sessionId) {
|
|
902
|
+
const { aggregator, config, settings } = this.bindings;
|
|
903
|
+
const id = sessionId ?? this.bindings.sessionId();
|
|
904
|
+
const snapshot = aggregator.snapshotFor(id === "" ? sessionId ?? "" : id);
|
|
905
|
+
const scopes = [
|
|
906
|
+
"session",
|
|
907
|
+
"daily",
|
|
908
|
+
"monthly"
|
|
909
|
+
].map((scope) => {
|
|
910
|
+
const used = scope === "session" ? snapshot.session : scope === "daily" ? snapshot.today : snapshot.thisMonth;
|
|
911
|
+
const cap = effectiveCap(settings, config, scope);
|
|
912
|
+
return {
|
|
913
|
+
scope,
|
|
914
|
+
capUsd: cap,
|
|
915
|
+
usedUsd: used.costUsd,
|
|
916
|
+
ratio: cap === null || cap <= 0 ? 0 : used.costUsd / cap,
|
|
917
|
+
tokens: used.inputTokens + used.outputTokens + used.cacheReadTokens + used.cacheWriteTokens,
|
|
918
|
+
carbonKg: used.carbonKg
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
const models = snapshot.models.map((entry) => ({
|
|
922
|
+
provider: entry.provider,
|
|
923
|
+
model: entry.model,
|
|
924
|
+
inputTokens: entry.inputTokens + entry.cacheReadTokens + entry.cacheWriteTokens,
|
|
925
|
+
outputTokens: entry.outputTokens,
|
|
926
|
+
costUsd: entry.costUsd,
|
|
927
|
+
carbonKg: entry.carbonKg,
|
|
928
|
+
latency: latencySummary(entry.latencyMs)
|
|
929
|
+
}));
|
|
930
|
+
const degraded = this.bindings.degradation();
|
|
931
|
+
return {
|
|
932
|
+
scopes,
|
|
933
|
+
models,
|
|
934
|
+
alerts: snapshot.alerts.map((alert) => ({ ...alert })),
|
|
935
|
+
blockedScopes: [...snapshot.blockedScopes],
|
|
936
|
+
currency: {
|
|
937
|
+
code: config.currency.code,
|
|
938
|
+
rate: config.currency.rate,
|
|
939
|
+
decimals: config.currency.decimals
|
|
940
|
+
},
|
|
941
|
+
alertsEnabled: settings.alertsEnabled,
|
|
942
|
+
desktopNotifications: settings.desktopNotifications,
|
|
943
|
+
degradedModel: degraded === void 0 ? null : degraded.to
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/** Apply the panel's runtime settings and return the refreshed snapshot. */
|
|
947
|
+
setSettings(settingsJson) {
|
|
948
|
+
let parsed;
|
|
949
|
+
try {
|
|
950
|
+
parsed = JSON.parse(settingsJson);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
throw new TypeError(`budget.setSettings requires a JSON payload: ${error instanceof Error ? error.message : "invalid JSON"}`);
|
|
953
|
+
}
|
|
954
|
+
const validated = BUDGET_SETTINGS_SCHEMA.parse(parsed);
|
|
955
|
+
const settings = this.bindings.settings;
|
|
956
|
+
settings.sessionCapUsd = validated.sessionCapUsd;
|
|
957
|
+
settings.dailyCapUsd = validated.dailyCapUsd;
|
|
958
|
+
settings.monthlyCapUsd = validated.monthlyCapUsd;
|
|
959
|
+
settings.alertsEnabled = validated.alertsEnabled;
|
|
960
|
+
settings.desktopNotifications = validated.desktopNotifications;
|
|
961
|
+
return this.status();
|
|
962
|
+
}
|
|
963
|
+
/** Lift one blocked scope and return the refreshed snapshot. */
|
|
964
|
+
unblock(scope) {
|
|
965
|
+
this.bindings.aggregator.unblockScope(scope);
|
|
966
|
+
this.bindings.onUnblock?.(scope);
|
|
967
|
+
return this.status();
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/command.ts
|
|
972
|
+
const EN_MESSAGES = {
|
|
973
|
+
hint: "[unblock <scope> | models]",
|
|
974
|
+
unlimited: "unlimited",
|
|
975
|
+
blocked: "BLOCKED",
|
|
976
|
+
usage: "usage: /budget [unblock session|daily|monthly | models]",
|
|
977
|
+
unknownScope: (scope) => `unknown scope "${scope}" (session | daily | monthly)`,
|
|
978
|
+
unblocked: (scope) => `budget scope "${scope}" unblocked`,
|
|
979
|
+
noModels: "no model usage recorded yet"
|
|
980
|
+
};
|
|
981
|
+
const ZH_MESSAGES = {
|
|
982
|
+
hint: "[unblock <scope> | models]",
|
|
983
|
+
unlimited: "不限",
|
|
984
|
+
blocked: "已阻断",
|
|
985
|
+
usage: "用法:/budget [unblock session|daily|monthly | models]",
|
|
986
|
+
unknownScope: (scope) => `未知作用域 "${scope}"(session | daily | monthly)`,
|
|
987
|
+
unblocked: (scope) => `预算作用域 "${scope}" 已解除阻断`,
|
|
988
|
+
noModels: "尚未记录到任何模型用量"
|
|
989
|
+
};
|
|
990
|
+
/** Format one USD amount through the display currency. */
|
|
991
|
+
function money(status, usd) {
|
|
992
|
+
return `${(usd * status.currency.rate).toFixed(status.currency.decimals)} ${status.currency.code}`;
|
|
993
|
+
}
|
|
994
|
+
/** Render the overview body for one snapshot. */
|
|
995
|
+
function renderBudgetOverview(status, messages) {
|
|
996
|
+
const lines = [];
|
|
997
|
+
for (const scope of status.scopes) {
|
|
998
|
+
const capText = scope.capUsd === null ? messages.unlimited : money(status, scope.capUsd);
|
|
999
|
+
const blocked = status.blockedScopes.includes(scope.scope) ? ` ${messages.blocked}` : "";
|
|
1000
|
+
lines.push(`${scope.scope}: ${money(status, scope.usedUsd)} / ${capText} (${Math.round(scope.ratio * 100)}% · ${scope.tokens} tokens · ${scope.carbonKg.toFixed(4)} kg CO2e)${blocked}`);
|
|
1001
|
+
}
|
|
1002
|
+
if (status.models.length === 0) lines.push(messages.noModels);
|
|
1003
|
+
else for (const model of status.models.slice(0, 8)) {
|
|
1004
|
+
const p50 = model.latency.p50 === null ? "-" : `${model.latency.p50}ms`;
|
|
1005
|
+
lines.push(`${model.provider}/${model.model}: ${money(status, model.costUsd)} (${model.inputTokens} in / ${model.outputTokens} out · p50 ${p50} · ${model.carbonKg.toFixed(4)} kg CO2e)`);
|
|
1006
|
+
}
|
|
1007
|
+
if (status.degradedModel !== null) lines.push(`degraded model: ${status.degradedModel}`);
|
|
1008
|
+
return lines.join("\n");
|
|
1009
|
+
}
|
|
1010
|
+
/** Parse the raw input after `/budget`. */
|
|
1011
|
+
function parseBudgetArgs(rawInput) {
|
|
1012
|
+
const text = rawInput.trim();
|
|
1013
|
+
if (text === "") return { kind: "overview" };
|
|
1014
|
+
if (text === "models") return { kind: "models" };
|
|
1015
|
+
const unblock = /^unblock\s+(session|daily|monthly)$/u.exec(text);
|
|
1016
|
+
if (unblock !== null) return {
|
|
1017
|
+
kind: "unblock",
|
|
1018
|
+
scope: unblock[1]
|
|
1019
|
+
};
|
|
1020
|
+
return { kind: "usage" };
|
|
1021
|
+
}
|
|
1022
|
+
/** Render the per-model breakdown body. */
|
|
1023
|
+
function renderBudgetModels(status, _messages) {
|
|
1024
|
+
if (status.models.length === 0) return _messages.noModels;
|
|
1025
|
+
return status.models.map((model) => `${model.provider}/${model.model}: ${money(status, model.costUsd)} (${model.inputTokens} in / ${model.outputTokens} out · p50 ${model.latency.p50 === null ? "-" : `${model.latency.p50}ms`})`).join("\n");
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Build the `/budget` command definition.
|
|
1029
|
+
*
|
|
1030
|
+
* @param service - the budget service (snapshot + unblock).
|
|
1031
|
+
* @param language - output language.
|
|
1032
|
+
* @returns the command definition.
|
|
1033
|
+
*/
|
|
1034
|
+
function budgetCommand(service, language = "en") {
|
|
1035
|
+
const messages = language === "zh" ? ZH_MESSAGES : EN_MESSAGES;
|
|
1036
|
+
return {
|
|
1037
|
+
name: "budget",
|
|
1038
|
+
description: "Show per-scope budget usage (tokens, cost, carbon, latency) and lift a blocked scope with \"unblock session|daily|monthly\"",
|
|
1039
|
+
input: { hint: messages.hint },
|
|
1040
|
+
handler: ({ rawInput, agent }) => {
|
|
1041
|
+
const parsed = parseBudgetArgs(rawInput);
|
|
1042
|
+
if (parsed.kind === "usage") return {
|
|
1043
|
+
kind: "error",
|
|
1044
|
+
text: messages.usage
|
|
1045
|
+
};
|
|
1046
|
+
const sessionId = String(agent.session.id);
|
|
1047
|
+
if (parsed.kind === "unblock") {
|
|
1048
|
+
const scope = parsed.scope;
|
|
1049
|
+
if (scope !== "session" && scope !== "daily" && scope !== "monthly") return {
|
|
1050
|
+
kind: "error",
|
|
1051
|
+
text: messages.unknownScope(scope)
|
|
1052
|
+
};
|
|
1053
|
+
const status = service.unblock(scope);
|
|
1054
|
+
return {
|
|
1055
|
+
kind: "success",
|
|
1056
|
+
text: `${messages.unblocked(scope)}\n${renderBudgetOverview(status, messages)}`
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const status = service.status(sessionId);
|
|
1060
|
+
return {
|
|
1061
|
+
kind: "success",
|
|
1062
|
+
text: parsed.kind === "models" ? renderBudgetModels(status, messages) : renderBudgetOverview(status, messages)
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
//#endregion
|
|
1068
|
+
//#region src/index.ts
|
|
1069
|
+
const name = "dsh-budget";
|
|
1070
|
+
/** Hard services: the session store every aggregation keys off. */
|
|
1071
|
+
const inject = ["sessions"];
|
|
1072
|
+
/** The short-circuit stream the blocker yields when a scope is blocked. */
|
|
1073
|
+
function blockedStream(message, code) {
|
|
1074
|
+
const finish = {
|
|
1075
|
+
kind: "error",
|
|
1076
|
+
failure: {
|
|
1077
|
+
message,
|
|
1078
|
+
code
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
return (async function* () {
|
|
1082
|
+
yield {
|
|
1083
|
+
type: "block-start",
|
|
1084
|
+
index: 0,
|
|
1085
|
+
blockType: "text"
|
|
1086
|
+
};
|
|
1087
|
+
yield {
|
|
1088
|
+
type: "text-delta",
|
|
1089
|
+
index: 0,
|
|
1090
|
+
text: message
|
|
1091
|
+
};
|
|
1092
|
+
yield {
|
|
1093
|
+
type: "block-end",
|
|
1094
|
+
index: 0,
|
|
1095
|
+
block: {
|
|
1096
|
+
type: "text",
|
|
1097
|
+
text: message
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
yield {
|
|
1101
|
+
type: "finish",
|
|
1102
|
+
reason: finish
|
|
1103
|
+
};
|
|
1104
|
+
})();
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Mount the plugin: resolve config, build the aggregator, wire the session
|
|
1108
|
+
* event feed, the budget checks, the `llm/stream` blocker, the webhook
|
|
1109
|
+
* alerts, the `budget` Remote service, and the `/budget` command.
|
|
1110
|
+
*
|
|
1111
|
+
* @param ctx - the plugin context (host).
|
|
1112
|
+
* @param config - raw plugin config.
|
|
1113
|
+
*/
|
|
1114
|
+
async function apply(ctx, config) {
|
|
1115
|
+
const resolved = resolveConfig(config);
|
|
1116
|
+
const logger = ctx.logger("budget");
|
|
1117
|
+
const aggregator = new BudgetAggregator(resolved);
|
|
1118
|
+
const settings = {
|
|
1119
|
+
sessionCapUsd: resolved.budgets.session ?? null,
|
|
1120
|
+
dailyCapUsd: resolved.budgets.daily ?? null,
|
|
1121
|
+
monthlyCapUsd: resolved.budgets.monthly ?? null,
|
|
1122
|
+
alertsEnabled: resolved.alertsEnabled,
|
|
1123
|
+
desktopNotifications: resolved.desktopNotifications
|
|
1124
|
+
};
|
|
1125
|
+
let degradation;
|
|
1126
|
+
const append = (session, type, event) => {
|
|
1127
|
+
queueMicrotask(() => {
|
|
1128
|
+
try {
|
|
1129
|
+
session.append(type, event);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
logger.warn(`audit append failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
};
|
|
1135
|
+
const hooks = {
|
|
1136
|
+
onAlert: (decision) => {
|
|
1137
|
+
if (decision.capUsd === void 0 || decision.state === "ok") return;
|
|
1138
|
+
logger.warn(`budget ${decision.scope} ${decision.state}: ${decision.usedUsd.toFixed(4)} USD of ${decision.capUsd} USD`);
|
|
1139
|
+
const session = currentSession();
|
|
1140
|
+
if (session !== void 0) append(session, ALERT_EVENT, {
|
|
1141
|
+
scope: decision.scope,
|
|
1142
|
+
kind: decision.state,
|
|
1143
|
+
usedUsd: decision.usedUsd,
|
|
1144
|
+
capUsd: decision.capUsd
|
|
1145
|
+
});
|
|
1146
|
+
if (resolved.webhookUrl !== void 0 && settings.alertsEnabled) sendWebhook(resolved.webhookUrl, {
|
|
1147
|
+
plugin: "dsh-budget",
|
|
1148
|
+
scope: decision.scope,
|
|
1149
|
+
kind: decision.state,
|
|
1150
|
+
usedUsd: decision.usedUsd,
|
|
1151
|
+
capUsd: decision.capUsd
|
|
1152
|
+
}, resolved.webhookTimeoutMs, logger);
|
|
1153
|
+
},
|
|
1154
|
+
onBlock: (scope, pair) => {
|
|
1155
|
+
degradation = pair;
|
|
1156
|
+
const session = currentSession();
|
|
1157
|
+
if (session !== void 0) append(session, BLOCK_EVENT, {
|
|
1158
|
+
scope,
|
|
1159
|
+
blocked: true,
|
|
1160
|
+
...pair === void 0 ? {} : { degradation: pair }
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
/** The session that the plugin attributes audits to (the latest observed). */
|
|
1165
|
+
let lastSession;
|
|
1166
|
+
const currentSession = () => lastSession;
|
|
1167
|
+
ctx.on("session/event", (session, event) => {
|
|
1168
|
+
try {
|
|
1169
|
+
lastSession = session;
|
|
1170
|
+
if (event.type === "request/header") {
|
|
1171
|
+
aggregator.setAttribution(event.data.header.config.provider, event.data.header.config.model);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (event.type === "assistant/message") {
|
|
1175
|
+
const usage = event.data.usage;
|
|
1176
|
+
if (usage === void 0) return;
|
|
1177
|
+
const sessionId = String(session.id);
|
|
1178
|
+
aggregator.recordUsage(sessionId, usage);
|
|
1179
|
+
checkBudgets(resolved, aggregator, sessionId, hooks, Date.now());
|
|
1180
|
+
}
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
logger.warn(`session "${session.id}": budget event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
if (ctx.get("llm") !== void 0) ctx.on("llm/stream", function(_options, next) {
|
|
1186
|
+
const blocked = aggregator.blockedScopes;
|
|
1187
|
+
if (blocked.length === 0) return next();
|
|
1188
|
+
const capText = blocked.map((scope) => {
|
|
1189
|
+
return {
|
|
1190
|
+
scope,
|
|
1191
|
+
cap: effectiveCap(settings, resolved, scope)
|
|
1192
|
+
};
|
|
1193
|
+
}).map((entry) => `${entry.scope} (${entry.cap === null ? "unlimited" : `${entry.cap} USD`})`).join(", ");
|
|
1194
|
+
const guidance = degradation === void 0 ? `budget blocked for ${capText}. Ask the user to lift the block with "/budget unblock <scope>" before continuing.` : `budget blocked for ${capText}. Continue with the degraded model "${degradation.to}" (or ask the user to lift the block with "/budget unblock <scope>").`;
|
|
1195
|
+
logger.warn(`blocking llm stream: ${guidance}`);
|
|
1196
|
+
return blockedStream(guidance, "BUDGET_BLOCKED");
|
|
1197
|
+
}, { prepend: true });
|
|
1198
|
+
if (resolved.latency.enabled) ctx.on("llm/stream", async function* (options, next) {
|
|
1199
|
+
const started = Date.now();
|
|
1200
|
+
const model = String(options.model ?? "");
|
|
1201
|
+
try {
|
|
1202
|
+
yield* await next();
|
|
1203
|
+
} finally {
|
|
1204
|
+
if (model !== "") aggregator.recordLatency(model, Date.now() - started);
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
await ctx.plugin(BudgetService, {
|
|
1208
|
+
aggregator,
|
|
1209
|
+
config: resolved,
|
|
1210
|
+
settings,
|
|
1211
|
+
sessionId: () => lastSession === void 0 ? "" : String(lastSession.id),
|
|
1212
|
+
degradation: () => degradation,
|
|
1213
|
+
onUnblock: (scope) => {
|
|
1214
|
+
const session = currentSession();
|
|
1215
|
+
if (session !== void 0) append(session, BLOCK_EVENT, {
|
|
1216
|
+
scope,
|
|
1217
|
+
blocked: false
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
const commands = ctx.get("commands");
|
|
1222
|
+
if (commands !== void 0) ctx.effect(() => commands.register(budgetCommand(ctx.get("budget"), resolved.outputLanguage)), "dsh-budget: /budget command");
|
|
1223
|
+
}
|
|
1224
|
+
//#endregion
|
|
1225
|
+
export { ALERT_EVENT, BLOCK_EVENT, BudgetAggregator, BudgetService, Config, apply, budgetCommand, checkBudgets, degradationFor, effectiveCap, inject, name, parseBudgetArgs, renderBudgetModels, renderBudgetOverview, resolveConfig };
|