@kenz1117/dsh-ui-usage-billing 0.9.0 → 0.9.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.
package/lib/index.js CHANGED
@@ -76,6 +76,14 @@ const PLAN_KNOWLEDGE = {
76
76
  "doubao-token-plan": {
77
77
  type: "code",
78
78
  subscriptionCny: 0
79
+ },
80
+ "minimax": {
81
+ type: "code",
82
+ subscriptionCny: 0
83
+ },
84
+ "minimax-token-plan": {
85
+ type: "code",
86
+ subscriptionCny: 0
79
87
  }
80
88
  };
81
89
  /** provider id(llm-pi-ai 设置键)→ plan 知识;未命中默认 token。 */
@@ -170,7 +178,8 @@ function currentRate() {
170
178
  /** Default share of traffic assumed to fall in the peak band (0..1). */
171
179
  const DEFAULT_PEAK_SHARE = .5;
172
180
  /**
173
- * 高峰时段判定(北京时间,UTC+8,无夏令时):09:00–12:00、14:00–18:00。
181
+ * 工作日高峰时段判定(北京时间,UTC+8,无夏令时):09:00–12:00、14:00–18:00。
182
+ * 周末(周六/周日)北京全天为低谷,不调用本函数判定峰/平。
174
183
  * @param beijingHour - 北京时间的小时数(0–23)。
175
184
  */
176
185
  function isPeakHour(beijingHour) {
@@ -179,12 +188,19 @@ function isPeakHour(beijingHour) {
179
188
  /**
180
189
  * 由时刻(epoch 毫秒)推断计费时段;时刻未知/非法时按高峰计(保守:未知
181
190
  * 时刻不低估成本,与社区 dsh-usage-chart 的 tierAt 语义一致)。
191
+ * 周末(北京时间周六/周日)全天不区分峰谷,统一按低谷价。
182
192
  * @param timeMs - Unix epoch 毫秒;null/undefined/NaN 视为未知。
183
193
  */
184
194
  function tierAt(timeMs) {
185
195
  if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return "peak";
196
+ if (isBeijingWeekend(timeMs)) return "offPeak";
186
197
  return isPeakHour((new Date(timeMs).getUTCHours() + 8) % 24) ? "peak" : "offPeak";
187
198
  }
199
+ /** 时刻是否落在北京时间周末(周六/周日)。 */
200
+ function isBeijingWeekend(timeMs) {
201
+ const day = new Date(timeMs + 8 * 36e5).getUTCDay();
202
+ return day === 0 || day === 6;
203
+ }
188
204
  /**
189
205
  * Built-in catalog of current mainstream models as of 2026-08-16, priced from
190
206
  * each provider's official price page. Domestic providers are OpenAI-API
@@ -711,9 +727,55 @@ const MODEL_KEY_ALIASES = {
711
727
  "k3": "kimi-k3",
712
728
  "kimi-k3": "kimi-k3"
713
729
  };
730
+ /**
731
+ * 模型 id 归一化:小写、去括号附注(如 `gpt5.6 luna(go)` 只看主体)、再去所有
732
+ * 非字母数字分隔符(空格 / 横杠 / 点 / 下划线)。用于日志里的模型 id 与计费
733
+ * 目录键做宽松匹配,提升「大小写/分隔符差异导致未收录」的识别率。
734
+ * @param id - 原始模型 id(日志或目录键)。
735
+ * @returns 归一化键(字母数字小写串)。
736
+ */
737
+ function canonModelId(id) {
738
+ return String(id).toLowerCase().replace(/\([^)]*\)/g, "").replace(/[^a-z0-9]+/g, "");
739
+ }
740
+ /**
741
+ * 目录常量键的归一化索引:归一化键 → 真实计费键。只索引静态来源(内置目录、
742
+ * 别名表、dsh-spend 兜底键);models.dev 补充条目是运行时注入,单独实时匹配。
743
+ */
744
+ const CATALOG_CANON_INDEX = (() => {
745
+ const map = /* @__PURE__ */ new Map();
746
+ const add = (candidate, target) => {
747
+ const canon = canonModelId(candidate);
748
+ if (canon !== "" && !map.has(canon)) map.set(canon, target);
749
+ };
750
+ for (const entry of MODEL_CATALOG) add(entry.key, entry.key);
751
+ for (const [alias, key] of Object.entries(MODEL_KEY_ALIASES)) add(alias, key);
752
+ for (const rate of FALLBACK_RATES) add(rate.key, rate.key);
753
+ return map;
754
+ })();
755
+ /**
756
+ * 解析真实日志模型 id → 计费目录键。先精确别名映射(既有行为);未命中时做
757
+ * 归一化匹配(忽略大小写/分隔符/括号附注),命中内置目录 / 别名目标 / 兜底键 /
758
+ * models.dev 补充键即返回其真实键;完全未知时保持原样(回退 other,不计费)。
759
+ * 供聚合层折叠与客户端渲染共用,两侧一致。
760
+ * @param id - 真实模型 id(日志里出现的形式)。
761
+ * @returns 计费目录键。
762
+ */
763
+ function resolveCatalogKey(id) {
764
+ const exact = MODEL_KEY_ALIASES[id] ?? id;
765
+ if (exact === id) {
766
+ const canon = canonModelId(id);
767
+ if (canon !== "") {
768
+ const hit = CATALOG_CANON_INDEX.get(canon);
769
+ if (hit !== void 0) return hit;
770
+ const extraHit = (liveExtraModels ?? []).find((item) => canonModelId(item.key) === canon);
771
+ if (extraHit !== void 0) return extraHit.key;
772
+ }
773
+ }
774
+ return exact;
775
+ }
714
776
  /** 取一个计费键的实时单价(实时覆盖 > dsh-spend 官方价兜底)。 */
715
777
  function livePriceOf(key) {
716
- const resolved = MODEL_KEY_ALIASES[key] ?? key;
778
+ const resolved = resolveCatalogKey(key);
717
779
  const live = livePrices?.[resolved];
718
780
  if (live !== void 0) return live;
719
781
  const fallback = FALLBACK_RATES.find((rate) => rate.key.toLowerCase() === resolved.toLowerCase());
@@ -726,7 +788,7 @@ function livePriceOf(key) {
726
788
  }
727
789
  /** Lookup a model by its stats key; falls back to the generic `other` entry. */
728
790
  function modelOf(key) {
729
- const resolved = MODEL_KEY_ALIASES[key] ?? key;
791
+ const resolved = resolveCatalogKey(key);
730
792
  const found = MODEL_CATALOG.find((entry) => entry.key === resolved);
731
793
  const extra = liveExtraModels?.find((item) => item.key === resolved);
732
794
  const base = found ?? (extra !== void 0 ? extraEntryOf(extra) : (() => {
@@ -766,7 +828,7 @@ function extraEntryOf(extra) {
766
828
  * 聚合层的计价闸门(目录外模型不产生费用,避免兜底档误估)。
767
829
  */
768
830
  function isPriced(key) {
769
- const resolved = MODEL_KEY_ALIASES[key] ?? key;
831
+ const resolved = resolveCatalogKey(key);
770
832
  if (MODEL_CATALOG.some((entry) => entry.key === resolved)) return true;
771
833
  if ((liveExtraModels ?? []).some((item) => item.key === resolved)) return true;
772
834
  return FALLBACK_RATES.some((rate) => rate.key.toLowerCase() === resolved.toLowerCase());
@@ -873,6 +935,14 @@ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
873
935
  "xiaomi-token-plan-cn",
874
936
  "xiaomi-token-plan-sgp"
875
937
  ];
938
+ /**
939
+ * 官方渠道 provider id 判定:`deepseek` 前缀(DeepSeek 官方直连)视为官方,
940
+ * 其余 provider(第三方中转/代理)视为「三方」。用于「官方 vs 三方」token、
941
+ * 调用与费用分桶展示;部署可由配置覆盖(见 {@link AggregateOptions})。
942
+ */
943
+ function isOfficialProvider(provider) {
944
+ return /^deepseek(?:-[a-z0-9-]+)?$/i.test(provider.trim());
945
+ }
876
946
  /** Zeroed usage accumulator. */
877
947
  function emptyUsage() {
878
948
  return {
@@ -881,7 +951,9 @@ function emptyUsage() {
881
951
  output: 0,
882
952
  cacheHit: 0,
883
953
  cacheMiss: 0,
884
- cost: 0
954
+ cost: 0,
955
+ officialCalls: 0,
956
+ officialCost: 0
885
957
  };
886
958
  }
887
959
  /**
@@ -893,8 +965,9 @@ function emptyUsage() {
893
965
  * @param key - the billing-catalog key this call belongs to.
894
966
  * @param subscription - whether the call went through a subscription plan; such calls never cost money.
895
967
  * @param timeMs - the call's wall-clock time (epoch ms); drives peak/off-peak pricing.
968
+ * @param official - whether the call went through the official DeepSeek channel (vs a third-party relay).
896
969
  */
897
- function foldUsage(acc, usage, key, subscription, timeMs) {
970
+ function foldUsage(acc, usage, key, subscription, timeMs, official = false) {
898
971
  const cacheHit = usage.cacheReadTokens ?? 0;
899
972
  const cacheMiss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
900
973
  acc.calls += 1;
@@ -902,12 +975,17 @@ function foldUsage(acc, usage, key, subscription, timeMs) {
902
975
  acc.output += usage.outputTokens;
903
976
  acc.cacheHit += cacheHit;
904
977
  acc.cacheMiss += cacheMiss;
905
- if (!subscription && isPriced(key)) acc.cost += computeCostAt(modelOf(key), {
906
- input: cacheHit + cacheMiss,
907
- cacheHit,
908
- cacheMiss,
909
- output: usage.outputTokens
910
- }, timeMs);
978
+ if (official) acc.officialCalls += 1;
979
+ if (!subscription && isPriced(key)) {
980
+ const thisCost = computeCostAt(modelOf(key), {
981
+ input: cacheHit + cacheMiss,
982
+ cacheHit,
983
+ cacheMiss,
984
+ output: usage.outputTokens
985
+ }, timeMs);
986
+ acc.cost += thisCost;
987
+ if (official) acc.officialCost += thisCost;
988
+ }
911
989
  }
912
990
  /** Local-time date stamp (the host runs in the user's timezone). */
913
991
  function dayStamp(time) {
@@ -976,9 +1054,11 @@ function turnState(turns, turn) {
976
1054
  * 并按轮次折叠每轮费用明细(turn/start → turn/end;调用按 (turn) 归组)。
977
1055
  * @param events - the session's persisted events in log order.
978
1056
  * @param subscriptionProviders - provider ids billed through subscription plans.
1057
+ * @param officialProviderIds - provider ids treated as the official DeepSeek channel
1058
+ * (default: any `deepseek`-prefixed id). Others count as third-party.
979
1059
  * @returns the per-session fold (cached by the incremental aggregator).
980
1060
  */
981
- function foldSession(events, subscriptionProviders) {
1061
+ function foldSession(events, subscriptionProviders, officialProviderIds) {
982
1062
  const fold = {
983
1063
  total: emptyUsage(),
984
1064
  byModel: /* @__PURE__ */ new Map(),
@@ -996,6 +1076,7 @@ function foldSession(events, subscriptionProviders) {
996
1076
  };
997
1077
  let key = "other";
998
1078
  let subscription = false;
1079
+ let official = false;
999
1080
  const turns = /* @__PURE__ */ new Map();
1000
1081
  for (const event of events) {
1001
1082
  fold.lastActive = Math.max(fold.lastActive, event.time);
@@ -1025,8 +1106,9 @@ function foldSession(events, subscriptionProviders) {
1025
1106
  }
1026
1107
  if (event.type === "request/header") {
1027
1108
  const { model, provider } = event.data.header.config;
1028
- key = MODEL_KEY_ALIASES[model] ?? model;
1109
+ key = resolveCatalogKey(model);
1029
1110
  subscription = subscriptionProviders.has(provider);
1111
+ official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
1030
1112
  continue;
1031
1113
  }
1032
1114
  if (event.type !== "assistant/message") continue;
@@ -1034,10 +1116,10 @@ function foldSession(events, subscriptionProviders) {
1034
1116
  if (usage === void 0) continue;
1035
1117
  const modelKey = key;
1036
1118
  const day = dayStamp(event.time);
1037
- foldUsage(fold.total, usage, modelKey, subscription, event.time);
1038
- foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time);
1039
- foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time);
1040
- foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time);
1119
+ foldUsage(fold.total, usage, modelKey, subscription, event.time, official);
1120
+ foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
1121
+ foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
1122
+ foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official);
1041
1123
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
1042
1124
  const state = turnState(turns, event.data.turn ?? -1);
1043
1125
  state.model = modelKey;
@@ -1086,6 +1168,8 @@ function mergeUsageInto(acc, cell) {
1086
1168
  acc.cacheHit += cell.cacheHit;
1087
1169
  acc.cacheMiss += cell.cacheMiss;
1088
1170
  acc.cost += cell.cost;
1171
+ acc.officialCalls += cell.officialCalls;
1172
+ acc.officialCost += cell.officialCost;
1089
1173
  }
1090
1174
  /**
1091
1175
  * Create the incremental usage aggregator.
@@ -1095,6 +1179,7 @@ function mergeUsageInto(acc, cell) {
1095
1179
  */
1096
1180
  function createUsageAggregator(persistence, options = {}) {
1097
1181
  const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
1182
+ const officialProviderIds = options.officialProviderIds === void 0 ? void 0 : new Set(options.officialProviderIds);
1098
1183
  const cache = /* @__PURE__ */ new Map();
1099
1184
  let lastDoc;
1100
1185
  let lastAt = 0;
@@ -1115,6 +1200,7 @@ function createUsageAggregator(persistence, options = {}) {
1115
1200
  const metas = await persistence.list();
1116
1201
  const seen = /* @__PURE__ */ new Set();
1117
1202
  const folds = [];
1203
+ const skipped = [];
1118
1204
  for (const meta of metas) {
1119
1205
  const id = String(meta.id);
1120
1206
  seen.add(id);
@@ -1127,18 +1213,24 @@ function createUsageAggregator(persistence, options = {}) {
1127
1213
  });
1128
1214
  continue;
1129
1215
  }
1130
- const { events } = await persistence.readFrom(meta.id, 0);
1131
- const fold = foldSession(events, subscriptionProviders);
1132
- cache.set(id, {
1133
- stamp,
1134
- fold
1135
- });
1136
- folds.push({
1137
- meta,
1138
- fold
1139
- });
1216
+ try {
1217
+ const { events } = await persistence.readFrom(meta.id, 0);
1218
+ const fold = foldSession(events, subscriptionProviders, officialProviderIds);
1219
+ cache.set(id, {
1220
+ stamp,
1221
+ fold
1222
+ });
1223
+ folds.push({
1224
+ meta,
1225
+ fold
1226
+ });
1227
+ } catch (error) {
1228
+ skipped.push(id);
1229
+ console.warn("[usage-billing] skip unreadable session", id, error);
1230
+ }
1140
1231
  }
1141
1232
  for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
1233
+ if (skipped.length > 0) console.warn(`[usage-billing] aggregated ${folds.length} sessions, skipped ${skipped.length} unreadable:`, skipped);
1142
1234
  const total = emptyUsage();
1143
1235
  const byModel = /* @__PURE__ */ new Map();
1144
1236
  const byDay = /* @__PURE__ */ new Map();
@@ -1255,6 +1347,8 @@ const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
1255
1347
  const MOONSHOT_BALANCE_URL = "https://api.moonshot.cn/v1/users/me/balance";
1256
1348
  /** 阶跃星辰 StepFun 官方账户信息接口(platform.stepfun.com/docs/api-reference/accounts/get)。 */
1257
1349
  const STEPFUN_BALANCE_URL = "https://api.stepfun.com/v1/accounts";
1350
+ /** 硅基流动 SiliconFlow 官方用户信息接口(docs.siliconflow.cn/cn/api-reference/user/query-user-info)。 */
1351
+ const SILICONFLOW_BALANCE_URL = "https://api.siliconflow.cn/v1/user/info";
1258
1352
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1259
1353
  function toNumber(value) {
1260
1354
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -1383,6 +1477,25 @@ function queryStepFun(ctx, apiKeyEnv) {
1383
1477
  };
1384
1478
  });
1385
1479
  }
1480
+ /**
1481
+ * Query the SiliconFlow (硅基流动) account balance.
1482
+ * @param ctx - host context carrying the credentials seam.
1483
+ * @param apiKeyEnv - credential reference resolving the SiliconFlow API key.
1484
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1485
+ */
1486
+ function querySiliconFlow(ctx, apiKeyEnv) {
1487
+ return queryBearerBalance(ctx, SILICONFLOW_BALANCE_URL, apiKeyEnv, "硅基流动", "硅基流动", (data) => {
1488
+ const doc = data;
1489
+ const inner = doc.data ?? doc;
1490
+ const totalBalance = toNumber(inner.balance ?? inner.balance_cny);
1491
+ return {
1492
+ provider: "硅基流动",
1493
+ displayName: "硅基流动",
1494
+ currency: "CNY",
1495
+ ...totalBalance !== void 0 ? { totalBalance } : {}
1496
+ };
1497
+ });
1498
+ }
1386
1499
  const QUERIERS = [
1387
1500
  {
1388
1501
  route: "deepseek",
@@ -1398,6 +1511,11 @@ const QUERIERS = [
1398
1511
  route: "stepfun",
1399
1512
  displayName: "阶跃星辰",
1400
1513
  querier: queryStepFun
1514
+ },
1515
+ {
1516
+ route: "siliconflow",
1517
+ displayName: "硅基流动",
1518
+ querier: querySiliconFlow
1401
1519
  }
1402
1520
  ];
1403
1521
  /**
@@ -1796,6 +1914,8 @@ const EMPTY_SUBSCRIPTION_KEYS = {
1796
1914
  kimiApiKey: "",
1797
1915
  zaiApiKey: "",
1798
1916
  opencodeApiKey: "",
1917
+ minmaxApiKey: "",
1918
+ openrouterApiKey: "",
1799
1919
  zaiRegion: "global"
1800
1920
  };
1801
1921
  /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
@@ -1816,10 +1936,12 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
1816
1936
  "ernie": "百度文心 Plan",
1817
1937
  "baidu": "百度文心 Plan",
1818
1938
  "wenxin": "百度文心 Plan",
1819
- "minimax": "MiniMax Coding Plan"
1939
+ "minimax": "MiniMax Coding Plan",
1940
+ "minimax-token-plan": "MiniMax Token Plan",
1941
+ "openrouter": "OpenRouter"
1820
1942
  };
1821
1943
  /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
1822
- const SUBSCRIPTION_ID_RE = /(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax)/i;
1944
+ const SUBSCRIPTION_ID_RE = /(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-token-plan|openrouter)/i;
1823
1945
  /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
1824
1946
  function isSubscriptionProviderId(providerId) {
1825
1947
  if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
@@ -1830,7 +1952,10 @@ const SUBSCRIPTION_ADAPTERS = {
1830
1952
  "kimi-coding": { collect: collectKimi },
1831
1953
  "zai-coding-cn": { collect: collectZai },
1832
1954
  "opencode": { collect: collectOpenCodeGo },
1833
- "opencode-go": { collect: collectOpenCodeGo }
1955
+ "opencode-go": { collect: collectOpenCodeGo },
1956
+ "minimax": { collect: collectMiniMax },
1957
+ "minimax-token-plan": { collect: collectMiniMax },
1958
+ "openrouter": { collect: collectOpenRouter }
1834
1959
  };
1835
1960
  /** 有额度适配器的 provider id 集合(识别用)。 */
1836
1961
  const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
@@ -2154,6 +2279,128 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2154
2279
  }
2155
2280
  }
2156
2281
  /**
2282
+ * 单条 MiniMax 记录抽一个窗口。`remaining_percent` 是剩余%;已用% = 100 - 剩余。
2283
+ * `status === 3` 表示不限量档,跳过该窗。导出供测试:纯函数。
2284
+ * @param record - 一条 model_remains 记录。
2285
+ * @param kind - 窗口类型(session=5h 滚动 / weekly=7d)。
2286
+ * @param remainPctKey - 剩余百分比字段。
2287
+ * @param statusKey - 限量状态字段(3=不限量)。
2288
+ * @param resetKey - 重置时刻字段(epoch 秒/毫秒/ISO)。
2289
+ */
2290
+ function minmaxWindow(record, kind, remainPctKey, statusKey, resetKey) {
2291
+ if (record === void 0) return null;
2292
+ if (Number(record[statusKey]) === 3) return null;
2293
+ const remain = numberOrNull(record[remainPctKey]);
2294
+ if (remain === null) return null;
2295
+ const usedPercent = round1(clampPercent(100 - (remain <= 1 ? remain * 100 : remain)) ?? 0);
2296
+ const resetsAt = toIso(record[resetKey]);
2297
+ return {
2298
+ kind,
2299
+ usedPercent,
2300
+ remainingPercent: round1(100 - usedPercent),
2301
+ ...resetsAt === null ? {} : { resetsAt }
2302
+ };
2303
+ }
2304
+ /**
2305
+ * 解析 MiniMax Token Plan `/v1/token_plan/remains` 响应。取 general(或 MiniMax-M*)
2306
+ * 一行抽出 5h/7d 窗口(total_count 常为 0,以 remaining_percent 为准),不按模型拆条。
2307
+ * 导出供测试:纯函数。
2308
+ * @param body - 接口响应 JSON。
2309
+ * @returns 窗口列表;无可用窗口时为空数组。
2310
+ */
2311
+ function parseMiniMaxRemains(body) {
2312
+ const doc = body ?? {};
2313
+ const payload = doc.data ?? doc;
2314
+ const rows = Array.isArray(payload.model_remains) ? payload.model_remains : Array.isArray(doc.model_remains) ? doc.model_remains : [];
2315
+ if (rows.length === 0) return [];
2316
+ const record = rows.find((row) => {
2317
+ const name = String(row.model_name ?? "").toLowerCase();
2318
+ return name === "general" || /^minimax-m/i.test(name);
2319
+ }) ?? rows[0] ?? void 0;
2320
+ return [minmaxWindow(record, "session", "current_interval_remaining_percent", "current_interval_status", "end_time"), minmaxWindow(record, "weekly", "current_weekly_remaining_percent", "current_weekly_status", "weekly_end_time")].filter((hit) => hit !== null);
2321
+ }
2322
+ /** Collect the MiniMax Token Plan quota. */
2323
+ async function collectMiniMax(keys, config, timeoutMs) {
2324
+ const apiKey = keys.minmaxApiKey.trim();
2325
+ const base = config.baseUrl ?? "https://www.minimaxi.com";
2326
+ if (apiKey === "") return {
2327
+ provider: config.provider,
2328
+ displayName: "MiniMax Coding Plan",
2329
+ status: "not-configured",
2330
+ windows: []
2331
+ };
2332
+ try {
2333
+ const windows = parseMiniMaxRemains(await requestJson(`${base}/v1/token_plan/remains`, { headers: {
2334
+ authorization: `Bearer ${apiKey}`,
2335
+ accept: "application/json"
2336
+ } }, timeoutMs));
2337
+ return {
2338
+ provider: config.provider,
2339
+ displayName: "MiniMax Coding Plan",
2340
+ status: windows.length > 0 ? "ok" : "invalid-response",
2341
+ windows
2342
+ };
2343
+ } catch (error) {
2344
+ return {
2345
+ provider: config.provider,
2346
+ displayName: "MiniMax Coding Plan",
2347
+ status: statusOf(error),
2348
+ windows: []
2349
+ };
2350
+ }
2351
+ }
2352
+ /**
2353
+ * 解析 OpenRouter `/api/v1/credits` 响应:已用% = total_usage / total_credits。
2354
+ * 导出供测试:纯函数。
2355
+ * @param body - 接口响应 JSON。
2356
+ * @returns 窗口列表;无有效额度时为 []。
2357
+ */
2358
+ function parseOpenRouterCredits(body) {
2359
+ const doc = body ?? {};
2360
+ const data = doc.data ?? doc;
2361
+ const total = numberOrNull(data.total_credits ?? data.credits);
2362
+ const used = numberOrNull(data.total_usage ?? data.usage);
2363
+ if (total === null || total <= 0 || used === null) return [];
2364
+ const usedPercent = round1(clampPercent(used / total * 100) ?? 0);
2365
+ const resetsAt = toIso(data.resets_at ?? data.next_reset_time);
2366
+ return [{
2367
+ kind: "billing",
2368
+ usedPercent,
2369
+ remainingPercent: round1(100 - usedPercent),
2370
+ ...resetsAt === null ? {} : { resetsAt }
2371
+ }];
2372
+ }
2373
+ /** Collect the OpenRouter prepaid credits usage. */
2374
+ async function collectOpenRouter(keys, config, timeoutMs) {
2375
+ const apiKey = keys.openrouterApiKey.trim();
2376
+ const base = config.baseUrl ?? "https://openrouter.ai";
2377
+ if (apiKey === "") return {
2378
+ provider: config.provider,
2379
+ displayName: "OpenRouter",
2380
+ status: "not-configured",
2381
+ windows: []
2382
+ };
2383
+ try {
2384
+ const windows = parseOpenRouterCredits(await requestJson(`${base}/api/v1/credits`, { headers: {
2385
+ authorization: `Bearer ${apiKey}`,
2386
+ accept: "application/json"
2387
+ } }, timeoutMs));
2388
+ return {
2389
+ provider: config.provider,
2390
+ displayName: "OpenRouter",
2391
+ status: windows.length > 0 ? "ok" : "invalid-response",
2392
+ windows
2393
+ };
2394
+ } catch (error) {
2395
+ return {
2396
+ provider: config.provider,
2397
+ displayName: "OpenRouter",
2398
+ status: statusOf(error),
2399
+ windows: []
2400
+ };
2401
+ }
2402
+ }
2403
+ /**
2157
2404
  * Collect quota for the given plans concurrently (adapter-backed plans only;
2158
2405
  * identified plans without an adapter are surfaced by the caller as "no
2159
2406
  * quota API" rows).
@@ -2223,6 +2470,18 @@ const SUBSCRIPTION_KEY_SOURCES = [
2223
2470
  {
2224
2471
  provider: "opencode-go",
2225
2472
  key: "opencodeApiKey"
2473
+ },
2474
+ {
2475
+ provider: "minimax",
2476
+ key: "minmaxApiKey"
2477
+ },
2478
+ {
2479
+ provider: "minimax-token-plan",
2480
+ key: "minmaxApiKey"
2481
+ },
2482
+ {
2483
+ provider: "openrouter",
2484
+ key: "openrouterApiKey"
2226
2485
  }
2227
2486
  ];
2228
2487
  /**
@@ -2519,7 +2778,9 @@ function apply(ctx, config = {}) {
2519
2778
  persistSnapshot(payload);
2520
2779
  res.end(JSON.stringify(payload));
2521
2780
  return;
2522
- } catch {}
2781
+ } catch (error) {
2782
+ console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
2783
+ }
2523
2784
  for (const candidate of candidates) try {
2524
2785
  const text = await readFile(candidate, "utf8");
2525
2786
  const doc = JSON.parse(text);
@@ -11,8 +11,8 @@
11
11
  */
12
12
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence';
13
13
  import type { TokenUsage } from '@deepseek-ai/dsh-llm';
14
- import { MODEL_KEY_ALIASES } from './client/pricing.ts';
15
- export { MODEL_KEY_ALIASES };
14
+ import { MODEL_KEY_ALIASES, resolveCatalogKey } from './client/pricing.ts';
15
+ export { MODEL_KEY_ALIASES, resolveCatalogKey };
16
16
  /**
17
17
  * 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
18
18
  * 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
@@ -21,10 +21,18 @@ export { MODEL_KEY_ALIASES };
21
21
  * 中覆盖。
22
22
  */
23
23
  export declare const DEFAULT_SUBSCRIPTION_PROVIDERS: readonly string[];
24
+ /**
25
+ * 官方渠道 provider id 判定:`deepseek` 前缀(DeepSeek 官方直连)视为官方,
26
+ * 其余 provider(第三方中转/代理)视为「三方」。用于「官方 vs 三方」token、
27
+ * 调用与费用分桶展示;部署可由配置覆盖(见 {@link AggregateOptions})。
28
+ */
29
+ export declare function isOfficialProvider(provider: string): boolean;
24
30
  /** Aggregation tuning options. */
25
31
  export interface AggregateOptions {
26
32
  /** 订阅制 provider id 列表;默认 {@link DEFAULT_SUBSCRIPTION_PROVIDERS}。 */
27
33
  subscriptionProviders?: readonly string[];
34
+ /** 官方渠道 provider id 列表;默认按 {@link isOfficialProvider} 判定(DeepSeek 官方直连)。 */
35
+ officialProviderIds?: readonly string[];
28
36
  }
29
37
  /** One model's aggregated usage plus estimated cost in CNY. */
30
38
  export interface ModelUsage {
@@ -36,6 +44,10 @@ export interface ModelUsage {
36
44
  cost: number;
37
45
  /** 该模型本次统计的所有调用是否都走订阅通道(coding/token plan);混合通道不置位。 */
38
46
  plan?: boolean;
47
+ /** 走官方渠道的调用数(DeepSeek 官方直连;其余为三方)。 */
48
+ officialCalls: number;
49
+ /** 走官方渠道的费用(CNY);三方费用 = cost - officialCost。 */
50
+ officialCost: number;
39
51
  }
40
52
  /** Zeroed usage accumulator. */
41
53
  export declare function emptyUsage(): ModelUsage;
@@ -48,8 +60,9 @@ export declare function emptyUsage(): ModelUsage;
48
60
  * @param key - the billing-catalog key this call belongs to.
49
61
  * @param subscription - whether the call went through a subscription plan; such calls never cost money.
50
62
  * @param timeMs - the call's wall-clock time (epoch ms); drives peak/off-peak pricing.
63
+ * @param official - whether the call went through the official DeepSeek channel (vs a third-party relay).
51
64
  */
52
- export declare function foldUsage(acc: ModelUsage, usage: TokenUsage, key: string, subscription: boolean, timeMs: number): void;
65
+ export declare function foldUsage(acc: ModelUsage, usage: TokenUsage, key: string, subscription: boolean, timeMs: number, official?: boolean): void;
53
66
  /** Local-time date stamp (the host runs in the user's timezone). */
54
67
  export declare function dayStamp(time: number): string;
55
68
  /** cwd 未知时工作区聚合的占位名(UI 显示 em dash,保持语言无关)。 */
@@ -180,13 +193,15 @@ export declare function messageTextLength(message: unknown): number;
180
193
  * 并按轮次折叠每轮费用明细(turn/start → turn/end;调用按 (turn) 归组)。
181
194
  * @param events - the session's persisted events in log order.
182
195
  * @param subscriptionProviders - provider ids billed through subscription plans.
196
+ * @param officialProviderIds - provider ids treated as the official DeepSeek channel
197
+ * (default: any `deepseek`-prefixed id). Others count as third-party.
183
198
  * @returns the per-session fold (cached by the incremental aggregator).
184
199
  */
185
200
  export declare function foldSession(events: readonly {
186
201
  type: string;
187
202
  time: number;
188
203
  data: never;
189
- }[], subscriptionProviders: ReadonlySet<string>): SessionFold;
204
+ }[], subscriptionProviders: ReadonlySet<string>, officialProviderIds?: ReadonlySet<string>): SessionFold;
190
205
  /**
191
206
  * 增量聚合器:按会话缓存折叠结果,用日志文件的 mtime+size 作失效键——
192
207
  * 日志没动的会话直接复用,只有写过的会话重新折叠;整份文档另有短 TTL
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 峰谷切换提醒浮层:切档前状态条(右下角或居中)。用 `position: fixed` 即可在
3
+ * 任意宿主容器内覆盖整个视口,因此不需要 portal。布局为「档位徽标 + 大号等宽
4
+ * 倒计时 + 一句说明 + 关闭」,克制冷调、无重力阴影。渲染是受控的:父组件把命中
5
+ * (hit)与偏好传入,显示剩余分钟并在切换后消失。
6
+ */
7
+ import type { PeakAlertConfig, PeakAlertHit } from './peak-alert.ts';
8
+ import type { UsageBillingKey } from './locales.ts';
9
+ /** Props: 命中的切档、偏好、国际化、关闭回调。 */
10
+ export interface PeakAlertBannerProps {
11
+ hit: PeakAlertHit;
12
+ config: PeakAlertConfig;
13
+ t: (key: UsageBillingKey) => string;
14
+ onDismiss: () => void;
15
+ }
16
+ /** 渲染一个切档前提醒状态条。 */
17
+ export declare function PeakAlertBanner({ hit, config, t, onDismiss }: PeakAlertBannerProps): React.ReactNode;
18
+ //# sourceMappingURL=PeakAlertBanner.d.ts.map
@@ -32,11 +32,11 @@ export interface ModelHealth {
32
32
  catalog?: readonly CatalogModel[];
33
33
  }
34
34
  /** 仪表盘分区 Tab id。 */
35
- export type DashboardTab = 'overview' | 'trends' | 'providers' | 'details' | 'pricing';
35
+ export type DashboardTab = 'overview' | 'trends' | 'providers' | 'details' | 'pricing' | 'settings';
36
36
  /**
37
- * Tab 定义(顺序即渲染顺序):概览=主数字/预算/KPI/热力图,趋势=趋势图/每轮费用,
38
- * 明细=厂商计费与订阅,统计=工作区/会话明细,费率=模型单价表。导出供测试断言
39
- * tab 与文案 key 对齐、decor 锚点落在正确分区。
37
+ * Tab 定义(顺序即渲染顺序):概览=主数字/KPI/热力图,趋势=趋势图/每轮费用,
38
+ * 明细=厂商计费与订阅,统计=工作区/会话明细,费率=模型单价表,设置=预算与峰谷提醒。
39
+ * 导出供测试断言 tab 与文案 key 对齐、decor 锚点落在正确分区。
40
40
  */
41
41
  export declare const DASHBOARD_TABS: readonly {
42
42
  id: DashboardTab;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 对话完成提醒:监听宿主 `sessions.list` 的会话状态迁移,一个会话
3
+ * `running → completed`(或不再 running)时触发一次桌面通知。
4
+ *
5
+ * 可选:跨 tab 只保留一个提醒 leader(Web Locks 优先,降级 localStorage 租约),
6
+ * 避免多窗口同时弹同一条。配置持久化在 localStorage(默认关闭,用户在面板设置开启)。
7
+ */
8
+ import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client';
9
+ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client';
10
+ /** 配置持久化 key:开启/关闭 + 提醒持续模式(0=常驻,其余=秒后自动关)。 */
11
+ export declare const COMPLETION_NOTIFY_KEY = "dsh-billing-completion-notify-v1";
12
+ export interface CompletionNotifyConfig {
13
+ enabled: boolean;
14
+ /** 通知停留秒数;0 = 常驻(requireInteraction)。 */
15
+ timeout: number;
16
+ }
17
+ /** 读取本地配置(缺失/损坏时回退默认)。 */
18
+ export declare function loadNotifyConfig(): CompletionNotifyConfig;
19
+ /** 保存配置;存储失败静默(通知功能降级为关闭,不影响其他能力)。 */
20
+ export declare function saveNotifyConfig(config: CompletionNotifyConfig): void;
21
+ /**
22
+ * 安装对话完成提醒:订阅 sessions.list 快照,用 `previousFinished` 记住每条
23
+ * 会话上次是运行中还是已完成;只有「之前运行中 → 现在已完成」的迁移才提醒,
24
+ * 首次快照只建立基线、不提醒。返回清理函数(dispose 时释放订阅)。
25
+ * @param list - `ctx.sessions.list`(宿主注入的会话列表快照源)。
26
+ */
27
+ export declare function installCompletionNotifier(list: ObservableSnapshot<SessionListState>, getConfig: () => CompletionNotifyConfig): () => void;
28
+ //# sourceMappingURL=completion-notify.d.ts.map