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.
Files changed (58) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +140 -0
  4. package/README.hi.md +140 -0
  5. package/README.md +140 -0
  6. package/README.pt.md +140 -0
  7. package/README.zh.md +140 -0
  8. package/SECURITY.md +37 -0
  9. package/THIRD_PARTY_NOTICES.md +23 -0
  10. package/cordis.patch.yml +73 -0
  11. package/lib/client.js +4893 -0
  12. package/lib/client.js.map +1 -0
  13. package/lib/index.js +1225 -0
  14. package/lib/typert.host.js +26 -0
  15. package/lib/types/aggregate/usage.d.ts +112 -0
  16. package/lib/types/aggregate/usage.d.ts.map +1 -0
  17. package/lib/types/client/BudgetTab.d.ts +18 -0
  18. package/lib/types/client/BudgetTab.d.ts.map +1 -0
  19. package/lib/types/client/index.d.ts +35 -0
  20. package/lib/types/client/index.d.ts.map +1 -0
  21. package/lib/types/client/locales.d.ts +42 -0
  22. package/lib/types/client/locales.d.ts.map +1 -0
  23. package/lib/types/client/present.d.ts +19 -0
  24. package/lib/types/client/present.d.ts.map +1 -0
  25. package/lib/types/client/remote.d.ts +270 -0
  26. package/lib/types/client/remote.d.ts.map +1 -0
  27. package/lib/types/client/styles.d.ts +12 -0
  28. package/lib/types/client/styles.d.ts.map +1 -0
  29. package/lib/types/command.d.ts +51 -0
  30. package/lib/types/command.d.ts.map +1 -0
  31. package/lib/types/config.d.ts +132 -0
  32. package/lib/types/config.d.ts.map +1 -0
  33. package/lib/types/estimate/carbon.d.ts +128 -0
  34. package/lib/types/estimate/carbon.d.ts.map +1 -0
  35. package/lib/types/estimate/cost.d.ts +71 -0
  36. package/lib/types/estimate/cost.d.ts.map +1 -0
  37. package/lib/types/estimate/latency-stats.d.ts +111 -0
  38. package/lib/types/estimate/latency-stats.d.ts.map +1 -0
  39. package/lib/types/estimate/models.d.ts +29 -0
  40. package/lib/types/estimate/models.d.ts.map +1 -0
  41. package/lib/types/estimate/prices.d.ts +85 -0
  42. package/lib/types/estimate/prices.d.ts.map +1 -0
  43. package/lib/types/estimate/sanitize.d.ts +56 -0
  44. package/lib/types/estimate/sanitize.d.ts.map +1 -0
  45. package/lib/types/events.d.ts +57 -0
  46. package/lib/types/events.d.ts.map +1 -0
  47. package/lib/types/governance.d.ts +67 -0
  48. package/lib/types/governance.d.ts.map +1 -0
  49. package/lib/types/index.d.ts +50 -0
  50. package/lib/types/index.d.ts.map +1 -0
  51. package/lib/types/service.d.ts +67 -0
  52. package/lib/types/service.d.ts.map +1 -0
  53. package/lib/types/typert.host.d.ts +258 -0
  54. package/lib/types/typert.host.d.ts.map +1 -0
  55. package/lib/types/wire.d.ts +629 -0
  56. package/lib/types/wire.d.ts.map +1 -0
  57. package/lib/wire-DVO8yw7L.js +4219 -0
  58. 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 };