balance-dsh 1.0.0 → 1.0.2

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 (3) hide show
  1. package/lib/client.js +23 -27
  2. package/lib/index.js +86 -27
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -17,10 +17,10 @@ window.__ModuleLoader__.load({
17
17
  /* 完全复刻设置按钮(.VOzbGW_trigger):width calc(100%+4px)、height 42px、margin 4px -2px、
18
18
  padding 0 10px 0 8px、gap 8px、font 14px/22px、color label-primary;
19
19
  左缘与设置按钮严格对齐,高度/颜色一致(图标与"余额"文字也统一 label-primary,金额 600 加粗) */
20
- .balance-footer{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:calc(100% + 4px);min-width:0;height:42px;margin:0 -2px;padding:0 10px 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:hidden;-webkit-user-select:none;user-select:none}
20
+ .balance-footer{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;min-width:0;height:42px;margin:0;padding:0 0 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:visible;-webkit-user-select:none;user-select:none}
21
21
  .balance-footer__icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-primary)}
22
22
  .balance-footer__label{white-space:nowrap;color:var(--dsw-alias-label-primary)}
23
- .balance-footer__value{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap;padding:2px 8px;border-radius:999px;margin-left:auto;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s)}
23
+ .balance-footer__value{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap;padding:2px 8px;border-radius:999px;margin-left:auto;margin-right:6px;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s)}
24
24
  .balance-footer__value:hover{filter:brightness(.85);transform:scale(1.06)}
25
25
  .balance-footer__value--healthy{color:var(--dsw-alias-state-success-primary);background:rgba(34,197,94,.15)}
26
26
  .balance-footer__value--low{color:#d97706;background:rgba(217,119,6,.15)}
@@ -41,6 +41,7 @@ window.__ModuleLoader__.load({
41
41
  .balance-fab-dark:hover{background:#cf3a3f}
42
42
  .balance-usage-line{box-sizing:border-box;display:flex;justify-content:center;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
43
43
  .balance-usage-line strong{color:var(--dsw-alias-label-primary);font-weight:600}
44
+ /* ── 跨会话统计面板(单击余额弹出) ── */
44
45
  /* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
45
46
  全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
46
47
  - wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
@@ -104,8 +105,19 @@ window.__ModuleLoader__.load({
104
105
  return input + output;
105
106
  }
106
107
 
107
- /** 分时段金额:高峰桶按目录价 ×2、空闲桶按目录价(与服务端 estimateCost 口径一致)。 */
108
+ /** 分时段金额:优先按服务端已标注的各模型 cost 累加(byModel);
109
+ * 无 byModel 时回退到旧的单模型逻辑:高峰桶按目录价 ×2、空闲桶按目录价(兼容子代理/旧数据)。 */
108
110
  function computeSplitCost(usage, p) {
111
+ if (Array.isArray(usage.byModel) && usage.byModel.length > 0) {
112
+ let total = 0;
113
+ for (const b of usage.byModel) {
114
+ total += typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : computeSingleSplitCost(b, p);
115
+ }
116
+ return total;
117
+ }
118
+ return computeSingleSplitCost(usage, p);
119
+ }
120
+ function computeSingleSplitCost(usage, p) {
109
121
  const costOf = (u) =>
110
122
  ((u.inputTokens ?? 0) * p.inputCnyPerMillion +
111
123
  (u.cacheReadTokens ?? 0) * p.cacheReadCnyPerMillion +
@@ -114,7 +126,6 @@ window.__ModuleLoader__.load({
114
126
  ((u.outputTokens ?? 0) * p.outputCnyPerMillion) / 1_000_000;
115
127
  return costOf(usage.peak) * 2 + costOf(usage.offpeak);
116
128
  }
117
-
118
129
  function formatTokens(n) {
119
130
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
120
131
  if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
@@ -302,6 +313,7 @@ window.__ModuleLoader__.load({
302
313
  // ─── 余额显示:展开态侧栏底部文字(¥ 图标+金额);收起态悬浮 FAB ──
303
314
  function BalanceFooter({ wide }) {
304
315
  const state = useBalance();
316
+ const openUsagePage = (e) => { e && e.preventDefault(); window.open("https://platform.deepseek.com/usage", "_blank", "noopener"); };
305
317
 
306
318
  if (!wide) {
307
319
  // 收起态:悬浮 FAB(原样保留)
@@ -427,13 +439,15 @@ window.__ModuleLoader__.load({
427
439
  ],
428
440
  }),
429
441
  }),
430
- jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
431
- jsx.jsx("a", {
442
+ jsx.jsx("span", {
443
+ className: "balance-footer__label",
444
+ children: "余额",
445
+ }),
446
+ jsx.jsx("span", {
432
447
  className: "balance-footer__value" + valueClass,
433
- href: "https://platform.deepseek.com/usage",
434
- target: "_blank",
435
- rel: "noreferrer",
448
+ onClick: openUsagePage,
436
449
  onContextMenu: onBalanceContextMenu,
450
+ title: "打开 DeepSeek 用量页",
437
451
  children: valueChildren,
438
452
  }),
439
453
  ];
@@ -445,11 +459,6 @@ window.__ModuleLoader__.load({
445
459
  }
446
460
 
447
461
  // ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
448
- // ─── 关闭按钮:侧边栏底部,设置上方 ───────────────────────────────────
449
- function ShutdownButton({ wide }) {
450
- return null;
451
- }
452
-
453
462
  function UsageCostLine({ useProjection, sessionId }) {
454
463
  const p = usePricing();
455
464
  const model = useCurrentModel();
@@ -582,19 +591,6 @@ function UsageCostLine({ useProjection, sessionId }) {
582
591
  ),
583
592
  );
584
593
 
585
-
586
- // 关闭按钮:显示在设置上方、余额右侧
587
- ctx.slots.inject("sidebar.footer.action", () =>
588
- ctx.slots.register(
589
- {
590
- name: "sidebar.footer.action",
591
- id: "shutdown",
592
- order: 10,
593
- },
594
- ShutdownButton,
595
- ),
596
- );
597
-
598
594
  // 对话统计区(对话框下方)的会话消耗行
599
595
  ctx.slots.inject("conversation.composer.dock", () =>
600
596
  ctx.slots.register(
package/lib/index.js CHANGED
@@ -176,17 +176,47 @@ function formatBalance(balance) {
176
176
  return `余额(${balance.currency || "CNY"}):${balance.total_balance}\n 充值余额:${balance.topped_up_balance}\n 赠送余额:${balance.granted_balance}`;
177
177
  }
178
178
 
179
- /** 从事件数组汇总 usage(跨时段分桶)。供 live 会话与持久化(子代理)会话复用。 */
180
- function sumEventsUsage(events, config) {
179
+ /** 构建模型切换点列表(按时间升序)。request/header、request/context 都在模型或请求设置变化时写入,
180
+ * 故可用它们把会话切成若干"模型段",每条 assistant/message 归属到"它之前最近一个切换点"的模型。
181
+ * 这样换不同价模型时(如 flash→pro)金额能按各段模型分别计价,而不是整会话用最后一个模型。 */
182
+ function collectModelSwitches(events) {
183
+ const switches = [];
184
+ for (const event of events ?? []) {
185
+ if (event.type === "request/header") {
186
+ const m = event.data?.header?.config?.model;
187
+ if (typeof m === "string" && m !== "") switches.push({ time: event.time ?? 0, model: m });
188
+ } else if (event.type === "request/context") {
189
+ const m = event.data?.model;
190
+ if (typeof m === "string" && m !== "") switches.push({ time: event.time ?? 0, model: m });
191
+ }
192
+ }
193
+ switches.sort((a, b) => a.time - b.time);
194
+ return switches;
195
+ }
196
+
197
+ /** 找到 time 之前最近一次模型切换点的模型;无切换点(或早于第一个)返回 ""(调用方回退到后备模型)。 */
198
+ function modelAtTime(switches, time) {
199
+ let model = "";
200
+ for (const sp of switches) {
201
+ if (sp.time <= time) model = sp.model;
202
+ else break;
203
+ }
204
+ return model;
205
+ }
206
+
207
+ /** 从事件数组汇总 usage(按模型分段 + 跨时段分桶)。
208
+ * 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
209
+ function sumEventsUsage(events, config, fallbackModel = "") {
210
+ const switches = collectModelSwitches(events);
211
+ const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
181
212
  const latestByStep = new Map();
182
- for (const event of events) {
213
+ for (const event of events ?? []) {
183
214
  if (event.type !== "assistant/message") continue;
184
215
  const usage = event.data?.usage;
185
216
  if (usage === void 0 || usage === null) continue;
186
217
  const turn = event.data?.turn;
187
218
  const step = event.data?.step;
188
219
  if (turn === void 0 || step === void 0) continue;
189
- // 事件带毫秒时间戳(event.time),用于按高峰/空闲时段分桶
190
220
  latestByStep.set(`${turn}/${step}`, { usage, time: event.time ?? Date.now() });
191
221
  }
192
222
  const add = (bucket, usage) => {
@@ -196,10 +226,27 @@ function sumEventsUsage(events, config) {
196
226
  bucket.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
197
227
  };
198
228
  const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
229
+ const byModel = new Map(); // model -> { model, peak, offpeak }
230
+ for (const { usage, time } of latestByStep.values()) {
231
+ const model = modelAtTime(switches, time) || fallback;
232
+ let e = byModel.get(model);
233
+ if (!e) { e = { model, peak: empty(), offpeak: empty() }; byModel.set(model, e); }
234
+ add(isPeakAt(time, config) ? e.peak : e.offpeak, usage);
235
+ }
236
+ // 聚合 + 转换 byModel(按金额未在前面算,这里只分桶 + 汇总字段)
199
237
  const peak = empty();
200
238
  const offpeak = empty();
201
- for (const { usage, time } of latestByStep.values()) {
202
- add(isPeakAt(time, config) ? peak : offpeak, usage);
239
+ const modelList = [];
240
+ for (const e of byModel.values()) {
241
+ modelList.push({ model: e.model, peak: e.peak, offpeak: e.offpeak });
242
+ peak.inputTokens += e.peak.inputTokens;
243
+ peak.outputTokens += e.peak.outputTokens;
244
+ peak.cacheReadTokens += e.peak.cacheReadTokens;
245
+ peak.cacheWriteTokens += e.peak.cacheWriteTokens;
246
+ offpeak.inputTokens += e.offpeak.inputTokens;
247
+ offpeak.outputTokens += e.offpeak.outputTokens;
248
+ offpeak.cacheReadTokens += e.offpeak.cacheReadTokens;
249
+ offpeak.cacheWriteTokens += e.offpeak.cacheWriteTokens;
203
250
  }
204
251
  const inputTokens = peak.inputTokens + offpeak.inputTokens;
205
252
  const outputTokens = peak.outputTokens + offpeak.outputTokens;
@@ -215,12 +262,14 @@ function sumEventsUsage(events, config) {
215
262
  totalTokens: billedInputTokens + outputTokens,
216
263
  peak,
217
264
  offpeak,
265
+ byModel: modelList,
218
266
  };
219
267
  }
220
268
 
221
269
  function sumSessionUsage(session, config) {
222
270
  if (session == null) return null;
223
- return sumEventsUsage(session.events ?? [], config);
271
+ // fallbackModel = detectModel(session):当 message 早于所有模型切换点时兜底,避免落到默认 flash 价。
272
+ return sumEventsUsage(session.events ?? [], config, detectModel(session));
224
273
  }
225
274
 
226
275
  function estimateCost(usage, pricing) {
@@ -244,8 +293,9 @@ function estimateCost(usage, pricing) {
244
293
  * 再对每个子代理用 inspectApiRemoteSession 读取持久化事件计算 usage。无子代理返回 {usage:null,debug}。 */
245
294
  async function sumSubagentUsage(ctx, sessionId, config) {
246
295
  if (ctx == null || sessionId === "") return { usage: null };
247
- // subagents 需通过 ctx.inject(["subagents"]) 注入后才可访问(见 apply 里的 webServer 注入列表)。
248
- const subagentsSvc = ctx.subagents != null ? ctx.subagents : (typeof ctx.get === "function" ? ctx.get("subagents") : void 0);
296
+ // 用 ctx.get("subagents") 安全获取(非注入,可空);不要直接读 ctx.subagents 属性,
297
+ // 否则未声明注入时会抛 "cannot get property subagents without inject"。
298
+ const subagentsSvc = typeof ctx.get === "function" ? ctx.get("subagents") : void 0;
249
299
  const listDescendants = subagentsSvc != null && typeof subagentsSvc.listDescendants === "function" ? subagentsSvc.listDescendants.bind(subagentsSvc) : void 0;
250
300
  // 1) 用 subagents.listDescendants 一次取完整后代树(含编号/层级/已结束)
251
301
  let descendants = [];
@@ -268,6 +318,7 @@ async function sumSubagentUsage(ctx, sessionId, config) {
268
318
  const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
269
319
  const peak = empty();
270
320
  const offpeak = empty();
321
+ const byModelMap = new Map(); // model -> { model, peak, offpeak }
271
322
  let count = 0;
272
323
  for (const id of childIds) {
273
324
  try {
@@ -284,6 +335,19 @@ async function sumSubagentUsage(ctx, sessionId, config) {
284
335
  offpeak.outputTokens += u.offpeak.outputTokens;
285
336
  offpeak.cacheReadTokens += u.offpeak.cacheReadTokens;
286
337
  offpeak.cacheWriteTokens += u.offpeak.cacheWriteTokens;
338
+ // 子代理也可能换模型,按模型同样分桶聚合
339
+ for (const b of u.byModel ?? []) {
340
+ let be = byModelMap.get(b.model);
341
+ if (!be) { be = { model: b.model, peak: empty(), offpeak: empty() }; byModelMap.set(b.model, be); }
342
+ be.peak.inputTokens += b.peak.inputTokens;
343
+ be.peak.outputTokens += b.peak.outputTokens;
344
+ be.peak.cacheReadTokens += b.peak.cacheReadTokens;
345
+ be.peak.cacheWriteTokens += b.peak.cacheWriteTokens;
346
+ be.offpeak.inputTokens += b.offpeak.inputTokens;
347
+ be.offpeak.outputTokens += b.offpeak.outputTokens;
348
+ be.offpeak.cacheReadTokens += b.offpeak.cacheReadTokens;
349
+ be.offpeak.cacheWriteTokens += b.offpeak.cacheWriteTokens;
350
+ }
287
351
  } catch {
288
352
  // 该子代理读取失败则跳过
289
353
  }
@@ -303,6 +367,7 @@ async function sumSubagentUsage(ctx, sessionId, config) {
303
367
  totalTokens: billedInputTokens + outputTokens,
304
368
  peak,
305
369
  offpeak,
370
+ byModel: [...byModelMap.values()],
306
371
  sessions: count,
307
372
  };
308
373
  return { usage };
@@ -375,7 +440,11 @@ export function apply(ctx, config = {}) {
375
440
  });
376
441
 
377
442
  // 2) /balance-api HTTP 接口:供客户端插件直接渲染余额(不经过命令,不污染会话日志)
378
- ctx.inject(["webServer", "subagents"], (httpCtx) => {
443
+ // 注意:不要在这里声 inject ["subagents"] —— 它依赖 dsh-subagent 服务;
444
+ // 若 balance-dsh 加载时该服务未就绪,Cordis 会跳过整个 callback,导致
445
+ // /balance-api 不注册(404)。改为只声 inject ["webServer"],子代理在
446
+ // sumSubagentUsage 内部用可空 ctx.get("subagents") 获取。
447
+ ctx.inject(["webServer"], (httpCtx) => {
379
448
  httpCtx.webServer.register({
380
449
  kind: "exact",
381
450
  path: "/balance-api",
@@ -425,6 +494,13 @@ export function apply(ctx, config = {}) {
425
494
  }
426
495
  // 客户端优先用自己解析的 model;为空则回退到服务端 detectModel
427
496
  const effectiveModel = model || detectedModel;
497
+ // 逐模型标注 cost(高峰期×2、空闲期×1),客户端直接按各模型 cost 累加,无需自己定价。
498
+ if (usage && Array.isArray(usage.byModel)) {
499
+ usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model)) }));
500
+ }
501
+ if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
502
+ subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model)) }));
503
+ }
428
504
  try {
429
505
  const data = await fetchBalance(apiKey);
430
506
  writeJson(res, 200, {
@@ -442,22 +518,5 @@ export function apply(ctx, config = {}) {
442
518
  },
443
519
  });
444
520
 
445
-
446
- httpCtx.webServer.register({
447
- kind: "exact",
448
- path: "/shutdown-api",
449
- handler: async (req, res) => {
450
- if (req.method !== "POST") {
451
- writeJson(res, 405, { ok: false, error: "method not allowed" });
452
- return;
453
- }
454
- writeJson(res, 200, { ok: true });
455
- const exit = ctx.get("appExit");
456
- setTimeout(() => {
457
- if (typeof exit === "function") exit(0);
458
- else process.exit(0);
459
- }, 150);
460
- },
461
- });
462
521
  });
463
522
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",