@kenz1117/dsh-ui-usage-billing 1.4.15 → 1.4.17

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
@@ -431,6 +431,37 @@ function canonIndex() {
431
431
  return map;
432
432
  }
433
433
  /**
434
+ * models.dev 补充条目的索引(归一化键 / 精确键各一份):补充列表可达数千条,
435
+ * 聚合折叠每条事件都查一次(此前是逐元素跑正则归一化的线性扫描),索引把
436
+ * 单次查询降到 O(1)。惰性构建,`applyLivePricing` 替换数组后按引用失效重建。
437
+ */
438
+ let extraIndexSource;
439
+ let extraCanonCache;
440
+ let extraKeyCache;
441
+ function buildExtraIndex() {
442
+ if (extraIndexSource === liveExtraModels) return;
443
+ const byCanon = /* @__PURE__ */ new Map();
444
+ const byKey = /* @__PURE__ */ new Map();
445
+ for (const item of liveExtraModels ?? []) {
446
+ if (!byKey.has(item.key)) byKey.set(item.key, item);
447
+ const canon = canonModelId(item.key);
448
+ if (canon !== "" && !byCanon.has(canon)) byCanon.set(canon, item);
449
+ }
450
+ extraIndexSource = liveExtraModels;
451
+ extraCanonCache = byCanon;
452
+ extraKeyCache = byKey;
453
+ }
454
+ /** 补充条目的归一化键索引(宽松匹配日志模型 id)。 */
455
+ function extraModelByCanon() {
456
+ buildExtraIndex();
457
+ return extraCanonCache ?? /* @__PURE__ */ new Map();
458
+ }
459
+ /** 补充条目的精确键索引(目录键直查)。 */
460
+ function extraModelByKey() {
461
+ buildExtraIndex();
462
+ return extraKeyCache ?? /* @__PURE__ */ new Map();
463
+ }
464
+ /**
434
465
  * 解析真实日志模型 id → 计费目录键。先精确别名映射(既有行为);未命中时做
435
466
  * 归一化匹配(忽略大小写/分隔符/括号附注),命中内置目录 / 别名目标 / 兜底键 /
436
467
  * models.dev 补充键即返回其真实键;完全未知时保持原样(回退 other,不计费)。
@@ -477,7 +508,7 @@ function lookupCandidate(candidate) {
477
508
  if (canon === "") return void 0;
478
509
  const hit = canonIndex().get(canon);
479
510
  if (hit !== void 0) return hit;
480
- return (liveExtraModels ?? []).find((item) => canonModelId(item.key) === canon)?.key;
511
+ return extraModelByCanon().get(canon)?.key;
481
512
  }
482
513
  function resolveCatalogKey(id) {
483
514
  const exact = userModelAliases?.[id] ?? modelKeyAliases()[id] ?? id;
@@ -486,7 +517,7 @@ function resolveCatalogKey(id) {
486
517
  if (canon !== "") {
487
518
  const hit = canonIndex().get(canon);
488
519
  if (hit !== void 0) return hit;
489
- const extraHit = (liveExtraModels ?? []).find((item) => canonModelId(item.key) === canon);
520
+ const extraHit = extraModelByCanon().get(canon);
490
521
  if (extraHit !== void 0) return extraHit.key;
491
522
  }
492
523
  for (const candidate of derivedKeyCandidates(id)) {
@@ -513,10 +544,11 @@ function livePriceOf(key) {
513
544
  function modelOf(key) {
514
545
  const resolved = resolveCatalogKey(key);
515
546
  const found = modelCatalog().find((entry) => entry.key === resolved);
516
- const extra = liveExtraModels?.find((item) => item.key === resolved);
517
- const base = found ?? (extra !== void 0 ? extraEntryOf(extra) : modelCatalog().at(-1) ?? UNSEEDED_OTHER);
547
+ const extra = extraModelByKey().get(resolved);
548
+ const base = found ?? (extra !== void 0 ? extraEntryOf(extra) : modelCatalog().find((entry) => entry.key === "other") ?? UNSEEDED_OTHER);
518
549
  const live = livePriceOf(resolved);
519
550
  if (live === void 0) return base;
551
+ if (base.price.offPeak !== void 0) return base;
520
552
  return {
521
553
  ...base,
522
554
  price: {
@@ -549,7 +581,7 @@ function extraEntryOf(extra) {
549
581
  function isPriced(key) {
550
582
  const resolved = resolveCatalogKey(key);
551
583
  if (modelCatalog().some((entry) => entry.key === resolved)) return true;
552
- if ((liveExtraModels ?? []).some((item) => item.key === resolved)) return true;
584
+ if (extraModelByKey().has(resolved)) return true;
553
585
  return FALLBACK_RATES.some((rate) => rate.key.toLowerCase() === resolved.toLowerCase());
554
586
  }
555
587
  /**
@@ -663,6 +695,11 @@ function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
663
695
  output: repriced.output * 2
664
696
  } : repriced, buckets, "CNY");
665
697
  if (priced.price.offPeak === void 0) return priceBandCost(priced.price, buckets, priced.price.currency);
698
+ if (entry.tierSemantics === "latency") {
699
+ const peak = priceBandCost(priced.price, buckets, priced.price.currency);
700
+ const off = priceBandCost(priced.price.offPeak, buckets, priced.price.currency);
701
+ return peak * peakShare + off * (1 - peakShare);
702
+ }
666
703
  return priceBandCost((timeMs < WEEKEND_OFFPEAK_START_MS ? tariffV1At(timeMs) : tierAt(timeMs)) === "peak" ? priced.price : priced.price.offPeak, buckets, priced.price.currency);
667
704
  }
668
705
  /**
@@ -2334,6 +2371,30 @@ function siteBucketKey(ref) {
2334
2371
  if (ref.kind === "direct") return `direct:${ref.provider}`;
2335
2372
  return "unknown";
2336
2373
  }
2374
+ /**
2375
+ * 站点桶按范围求和(usage_stats 工具的 bySite/relay 口径):bySite 全量累计;
2376
+ * relay 只计中转站桶(`site:<origin>` 键)——`direct:*` 与 `unknown` 不计入,
2377
+ * 否则 relay 总额与 all 相同,中转站费用被高估。
2378
+ */
2379
+ function sumSiteBuckets(bySite, range) {
2380
+ let cost = 0;
2381
+ let calls = 0;
2382
+ let input = 0;
2383
+ let output = 0;
2384
+ for (const [bucket, usage] of Object.entries(bySite)) {
2385
+ if (range === "relay" && !bucket.startsWith("site:")) continue;
2386
+ cost += usage.cost;
2387
+ calls += usage.calls;
2388
+ input += usage.input;
2389
+ output += usage.output;
2390
+ }
2391
+ return {
2392
+ cost,
2393
+ calls,
2394
+ input,
2395
+ output
2396
+ };
2397
+ }
2337
2398
  /** Zeroed usage accumulator. */
2338
2399
  function emptyUsage() {
2339
2400
  return {
@@ -2358,8 +2419,11 @@ function emptyUsage() {
2358
2419
  * @param subscription - whether the call went through a subscription plan; such calls never cost money.
2359
2420
  * @param timeMs - the call's wall-clock time (epoch ms); drives peak/off-peak pricing.
2360
2421
  * @param official - whether the call went through the official DeepSeek channel (vs a third-party relay).
2422
+ * @param pricedCost - 调用方预算好的本次费用(同一条消息折叠进多个桶时计价结果相同,
2423
+ * 逐桶重算会让最热路径付出 N 倍常数);提供时跳过内部的计价闸门与计算,
2424
+ * 调用方保证其已按 `!subscription && isPriced(key)` 判定。
2361
2425
  */
2362
- function foldUsage(acc, usage, key, subscription, timeMs, official = false) {
2426
+ function foldUsage(acc, usage, key, subscription, timeMs, official = false, pricedCost) {
2363
2427
  const cacheHit = usage.cacheReadTokens ?? 0;
2364
2428
  const cacheMiss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
2365
2429
  acc.calls += 1;
@@ -2370,6 +2434,11 @@ function foldUsage(acc, usage, key, subscription, timeMs, official = false) {
2370
2434
  acc.cacheMiss += cacheMiss;
2371
2435
  if ((usage.cacheWriteTokens ?? 0) > 0) acc.cacheWrite = (acc.cacheWrite ?? 0) + (usage.cacheWriteTokens ?? 0);
2372
2436
  if (official) acc.officialCalls += 1;
2437
+ if (pricedCost !== void 0) {
2438
+ acc.cost += pricedCost;
2439
+ if (official) acc.officialCost += pricedCost;
2440
+ return;
2441
+ }
2373
2442
  if (!subscription && isPriced(key)) {
2374
2443
  const thisCost = computeCostAt(modelOf(key), {
2375
2444
  input: cacheHit + cacheMiss,
@@ -2925,14 +2994,22 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
2925
2994
  }
2926
2995
  const modelKey = key;
2927
2996
  const day = dayStamp(event.time);
2928
- if (!subscription && !isPriced(modelKey)) fold.unpricedModels.add(modelKey);
2929
- foldUsage(fold.total, usage, modelKey, subscription, event.time, official);
2930
- foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
2931
- foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
2932
- foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official);
2933
- foldUsage(modelDaySiteCell(fold.byDayModelsSite, day, modelKey, siteBucket), usage, modelKey, subscription, event.time, official);
2934
- foldUsage(usageCell(fold.bySite, siteBucket), usage, modelKey, subscription, event.time, official);
2935
- foldUsage(usageCell(fold.byTier, tierAt(event.time)), usage, modelKey, subscription, event.time, official);
2997
+ const priced = !subscription && isPriced(modelKey);
2998
+ if (!subscription && !priced) fold.unpricedModels.add(modelKey);
2999
+ const entry = priced ? modelOf(modelKey) : void 0;
3000
+ const thisCost = entry === void 0 ? void 0 : computeCostAt(entry, {
3001
+ input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
3002
+ cacheHit: usage.cacheReadTokens ?? 0,
3003
+ cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
3004
+ output: usage.outputTokens
3005
+ }, event.time);
3006
+ foldUsage(fold.total, usage, modelKey, subscription, event.time, official, thisCost);
3007
+ foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official, thisCost);
3008
+ foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official, thisCost);
3009
+ foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official, thisCost);
3010
+ foldUsage(modelDaySiteCell(fold.byDayModelsSite, day, modelKey, siteBucket), usage, modelKey, subscription, event.time, official, thisCost);
3011
+ foldUsage(usageCell(fold.bySite, siteBucket), usage, modelKey, subscription, event.time, official, thisCost);
3012
+ foldUsage(usageCell(fold.byTier, tierAt(event.time)), usage, modelKey, subscription, event.time, official, thisCost);
2936
3013
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
2937
3014
  const turn = event.data.turn ?? -1;
2938
3015
  const state = turnState(turns, turn);
@@ -2941,23 +3018,16 @@ function foldInto(fold, machine, events, subscriptionProviders, officialProvider
2941
3018
  state.output += usage.outputTokens;
2942
3019
  state.cacheHit += usage.cacheReadTokens ?? 0;
2943
3020
  state.cacheMiss += usage.inputTokens + (usage.cacheWriteTokens ?? 0);
2944
- if (!subscription && isPriced(modelKey)) {
2945
- const buckets = {
2946
- input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
2947
- cacheHit: usage.cacheReadTokens ?? 0,
2948
- cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
2949
- output: usage.outputTokens
2950
- };
2951
- const fullCost = computeCostAt(modelOf(modelKey), buckets, event.time);
2952
- state.cost += fullCost;
2953
- const outputCost = computeCostAt(modelOf(modelKey), {
3021
+ if (thisCost !== void 0 && entry !== void 0) {
3022
+ state.cost += thisCost;
3023
+ const outputCost = computeCostAt(entry, {
2954
3024
  input: 0,
2955
3025
  cacheHit: 0,
2956
3026
  cacheMiss: 0,
2957
3027
  output: usage.outputTokens
2958
3028
  }, event.time);
2959
3029
  fold.roles.outputCost += outputCost;
2960
- fold.roles.inputCost += fullCost - outputCost;
3030
+ fold.roles.inputCost += thisCost - outputCost;
2961
3031
  }
2962
3032
  if (state.startedAt === Number.MAX_SAFE_INTEGER) state.startedAt = event.time;
2963
3033
  const stepNum = event.data.step;
@@ -4206,7 +4276,7 @@ async function queryTencentTokenPlan(ctx, apiKeyEnv) {
4206
4276
  if (credential === void 0) return {
4207
4277
  provider,
4208
4278
  displayName: provider,
4209
- error: "unauthorized"
4279
+ error: "unconfigured"
4210
4280
  };
4211
4281
  const doRequest = async () => {
4212
4282
  const teamId = firstEnabledTeamId(await callTokenHub(credential.secretId, credential.secretKey, "DescribeTokenPlanList", {}));
@@ -4954,12 +5024,7 @@ async function fetchRateFrom(sources) {
4954
5024
  }
4955
5025
  }
4956
5026
  async function fetchRate() {
4957
- for (const source of RATE_SOURCES) {
4958
- const text = await fetchText(source.url);
4959
- if (text === null) continue;
4960
- const value = source.parse(text);
4961
- if (value !== void 0 && Number.isFinite(value) && value > 0) return value;
4962
- }
5027
+ return fetchRateFrom(RATE_SOURCES);
4963
5028
  }
4964
5029
  /** OpenRouter model rows with usable USD unit prices, or undefined on failure. */
4965
5030
  async function fetchRouterModels() {
@@ -4986,13 +5051,15 @@ async function fetchRouterModels() {
4986
5051
  }
4987
5052
  return models;
4988
5053
  }
4989
- /** Match one catalog key's candidates: exact id first, then a single strong substring hit. */
5054
+ /** Match one catalog key's candidates: exact id first, then per-hint unique strong substring. */
4990
5055
  function matchRouterModel(hints, models) {
4991
5056
  const exact = models.find((model) => hints.some((hint) => model.id === hint));
4992
5057
  if (exact !== void 0) return exact;
4993
- const strong = models.filter((model) => hints.some((hint) => hint.length >= 8 && model.id.includes(hint)));
4994
- if (strong.length !== 1) return void 0;
4995
- return strong[0];
5058
+ for (const hint of hints) {
5059
+ if (hint.length < 8) continue;
5060
+ const strong = models.filter((model) => model.id.includes(hint));
5061
+ if (strong.length === 1) return strong[0];
5062
+ }
4996
5063
  }
4997
5064
  /** Map router matches onto catalog keys; undefined when nothing matched. */
4998
5065
  function buildPrices(models) {
@@ -5192,10 +5259,38 @@ const subscriptionGate = createCooldownGate({
5192
5259
  failures: 3,
5193
5260
  cooldownMs: 6e4
5194
5261
  });
5262
+ /**
5263
+ * 解析 OpenCode 客户端凭据文档(~/.local/share/opencode/auth.json)里的
5264
+ * OpenCode Go token。两种形态都收:
5265
+ * - 平铺:`{"token": "..."}` / `{"key": "..."}` / `{"apiKey": "..."}` / 裸字符串;
5266
+ * - 按 provider 分桶(实测形态):`{"opencode-go": {"type": "api", "key": "..."}}`,
5267
+ * 桶序 opencode-go → opencode → zen 即优先序。
5268
+ * 识别不出返回空串,绝不抛错——这只是一次便利回退。
5269
+ */
5270
+ function parseOpenCodeAuthDocument(document) {
5271
+ if (typeof document === "string" && document.trim() !== "") return document.trim();
5272
+ if (document === null || typeof document !== "object") return "";
5273
+ const record = document;
5274
+ const topToken = record["token"] ?? record["key"] ?? record["apiKey"];
5275
+ if (typeof topToken === "string" && topToken !== "") return topToken;
5276
+ for (const bucket of [
5277
+ "opencode-go",
5278
+ "opencode",
5279
+ "zen"
5280
+ ]) {
5281
+ const entry = record[bucket];
5282
+ if (entry === null || typeof entry !== "object") continue;
5283
+ const entryRecord = entry;
5284
+ const bucketToken = entryRecord["key"] ?? entryRecord["token"] ?? entryRecord["apiKey"];
5285
+ if (typeof bucketToken === "string" && bucketToken !== "") return bucketToken;
5286
+ }
5287
+ return "";
5288
+ }
5195
5289
  /** 空凭据:全部未配置时的初始值。 */
5196
5290
  const EMPTY_SUBSCRIPTION_KEYS = {
5197
5291
  kimiApiKey: "",
5198
5292
  zaiApiKey: "",
5293
+ zaiCnApiKey: "",
5199
5294
  opencodeApiKey: "",
5200
5295
  minmaxApiKey: "",
5201
5296
  openrouterApiKey: "",
@@ -5242,6 +5337,7 @@ function isSubscriptionProviderId(providerId) {
5242
5337
  const SUBSCRIPTION_ADAPTERS = {
5243
5338
  "kimi-coding": { collect: collectKimi },
5244
5339
  "zai-coding-cn": { collect: collectZai },
5340
+ "zai-coding": { collect: collectZai },
5245
5341
  "opencode": { collect: collectOpenCodeGo },
5246
5342
  "opencode-go": { collect: collectOpenCodeGo },
5247
5343
  "minimax": { collect: collectMiniMax },
@@ -5269,7 +5365,8 @@ function identifySubscriptionPlans(providers) {
5269
5365
  provider: id,
5270
5366
  displayName: SUBSCRIPTION_DISPLAY_NAMES[id] ?? id,
5271
5367
  adapter: ADAPTER_PROVIDER_IDS.has(id),
5272
- ...id === "zai-coding-cn" ? { region: "bigmodel-cn" } : {}
5368
+ ...id === "zai-coding-cn" ? { region: "bigmodel-cn" } : {},
5369
+ ...id === "zai-coding" ? { region: "global" } : {}
5273
5370
  });
5274
5371
  }
5275
5372
  return out;
@@ -5498,13 +5595,28 @@ function parseZai(quotaBody, subscriptionBody) {
5498
5595
  ].filter((hit) => hit !== null)
5499
5596
  };
5500
5597
  }
5598
+ /**
5599
+ * Z.ai 用 HTTP 200 包裹业务错误(实测假 key 回 `{"code":401,"msg":...,"success":false}`),
5600
+ * 只看 HTTP 层会把鉴权失败误报成 invalid-response。这里识别信封错误并归类;
5601
+ * 非 success=false 的信封(含缺字段的旧形态)交回常规解析。
5602
+ */
5603
+ function zaiEnvelopeStatus(body) {
5604
+ if (body === null || typeof body !== "object") return void 0;
5605
+ const record = body;
5606
+ if (record["success"] !== false) return void 0;
5607
+ const code = numberOrNull$1(record["code"]);
5608
+ if (code === 401 || code === 403) return "unauthorized";
5609
+ if (code === 429) return "rate-limited";
5610
+ }
5501
5611
  /** Collect the Z.ai Coding Plan quota. */
5502
5612
  async function collectZai(keys, config, timeoutMs) {
5503
- const apiKey = keys.zaiApiKey.trim();
5504
- const host = (config.region ?? keys.zaiRegion ?? "global") === "bigmodel-cn" ? "https://open.bigmodel.cn" : "https://api.z.ai";
5613
+ const region = config.region ?? keys.zaiRegion ?? "global";
5614
+ const apiKey = (region === "bigmodel-cn" ? keys.zaiCnApiKey : keys.zaiApiKey).trim();
5615
+ const host = region === "bigmodel-cn" ? "https://open.bigmodel.cn" : "https://api.z.ai";
5616
+ const displayName = region === "bigmodel-cn" ? "Z.ai Coding Plan(国内)" : "Z.ai Coding Plan";
5505
5617
  if (apiKey === "") return {
5506
5618
  provider: config.provider,
5507
- displayName: "Z.ai Coding Plan",
5619
+ displayName,
5508
5620
  status: "not-configured",
5509
5621
  windows: []
5510
5622
  };
@@ -5514,14 +5626,22 @@ async function collectZai(keys, config, timeoutMs) {
5514
5626
  accept: "application/json"
5515
5627
  } };
5516
5628
  const quota = await requestJson(`${host}/api/monitor/usage/quota/limit`, init, timeoutMs);
5629
+ const envelope = zaiEnvelopeStatus(quota);
5630
+ if (envelope !== void 0) return {
5631
+ provider: config.provider,
5632
+ displayName,
5633
+ status: envelope,
5634
+ windows: []
5635
+ };
5517
5636
  let subscription = null;
5518
5637
  try {
5519
5638
  subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
5639
+ if (zaiEnvelopeStatus(subscription) !== void 0) subscription = null;
5520
5640
  } catch {}
5521
5641
  const parsed = parseZai(quota, subscription);
5522
5642
  return {
5523
5643
  provider: config.provider,
5524
- displayName: "Z.ai Coding Plan",
5644
+ displayName,
5525
5645
  plan: parsed.plan,
5526
5646
  status: parsed.windows.length > 0 ? "ok" : "invalid-response",
5527
5647
  windows: parsed.windows
@@ -5529,7 +5649,7 @@ async function collectZai(keys, config, timeoutMs) {
5529
5649
  } catch (error) {
5530
5650
  return {
5531
5651
  provider: config.provider,
5532
- displayName: "Z.ai Coding Plan",
5652
+ displayName,
5533
5653
  status: statusOf$1(error),
5534
5654
  windows: []
5535
5655
  };
@@ -6325,22 +6445,35 @@ function isLoopbackPeer(req) {
6325
6445
  * 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
6326
6446
  * 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
6327
6447
  /**
6328
- * 信任主机名归一化:去空白、去端口、转小写,丢弃空项。与请求侧
6329
- * `host.split(':')[0].toLowerCase()` 同口径,因此匹配忽略大小写与端口。
6448
+ * Host 头/名单条目的主机名部分:IPv6 字面量带方括号(`[::1]:3080`),整体去括号;
6449
+ * 裸 IPv6(多个冒号、无方括号)整体是字面量;其余按首个冒号去端口。统一小写。
6450
+ * (此前一律 `split(':')[0]`:`[::1]:3080` 截成 `[`,IPv6 回环请求被误拒,
6451
+ * trustedHosts 也配不进去——fail-closed 无安全暴露,但本机 IPv6 场景功能失效。)
6452
+ */
6453
+ function hostNameOf(host) {
6454
+ const trimmed = host.trim();
6455
+ const bracket = /^\[([0-9a-fA-F:]+)\](?::\d+)?$/.exec(trimmed);
6456
+ if (bracket !== null) return (bracket[1] ?? "").toLowerCase();
6457
+ if ((trimmed.match(/:/g) ?? []).length > 1) return trimmed.toLowerCase();
6458
+ return trimmed.split(":")[0]?.toLowerCase() ?? "";
6459
+ }
6460
+ /**
6461
+ * 信任主机名归一化:去空白、去端口、转小写,丢弃空项(IPv6 经 {@link hostNameOf}
6462
+ * 去方括号)。与请求侧 Host 头解析同口径,因此匹配忽略大小写与端口。
6330
6463
  * @param hosts - 配置里的原始名单。
6331
6464
  * @returns 归一化后的主机名集合(空集 = 与历史版本行为一致)。
6332
6465
  */
6333
6466
  function normalizeTrustedHosts(hosts) {
6334
6467
  if (hosts === void 0) return /* @__PURE__ */ new Set();
6335
- const names = hosts.filter((host) => typeof host === "string").map((host) => host.trim().split(":")[0]?.toLowerCase() ?? "").filter((host) => host !== "");
6468
+ const names = hosts.filter((host) => typeof host === "string").map((host) => hostNameOf(host)).filter((host) => host !== "");
6336
6469
  return new Set(names);
6337
6470
  }
6338
6471
  function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
6339
6472
  const host = req.headers.host;
6340
6473
  if (host === void 0 || host === "") return true;
6341
- const name = host.split(":")[0];
6342
- if (name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
6343
- return name !== void 0 && trustedHosts.has(name.toLowerCase());
6474
+ const name = hostNameOf(host);
6475
+ if (name === "localhost" || name === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
6476
+ return name !== "" && trustedHosts.has(name);
6344
6477
  }
6345
6478
  /** 校验 Origin 头是否回环(写操作用,防止跨站表单/fetch 改写设置)。Origin 缺失
6346
6479
  * (curl 或同源 fetch 不带)视为放行,交给下方的 Content-Type 校验兜底;Origin 存在
@@ -6348,7 +6481,8 @@ function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
6348
6481
  function isLoopbackOrigin(origin) {
6349
6482
  if (origin === void 0 || origin === "") return true;
6350
6483
  try {
6351
- const host = new URL(origin).hostname;
6484
+ const raw = new URL(origin).hostname;
6485
+ const host = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
6352
6486
  return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
6353
6487
  } catch {
6354
6488
  return false;
@@ -6516,6 +6650,10 @@ const SUBSCRIPTION_KEY_SOURCES = [
6516
6650
  },
6517
6651
  {
6518
6652
  provider: "zai-coding-cn",
6653
+ key: "zaiCnApiKey"
6654
+ },
6655
+ {
6656
+ provider: "zai-coding",
6519
6657
  key: "zaiApiKey"
6520
6658
  },
6521
6659
  {
@@ -6655,7 +6793,7 @@ async function resolveSubscriptionKeys(settings, credentials) {
6655
6793
  if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
6656
6794
  } catch {}
6657
6795
  }
6658
- if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiApiKey !== "") keys.zaiRegion = "bigmodel-cn";
6796
+ if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
6659
6797
  if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
6660
6798
  keys.anthropicApiKey = await readClaudeOAuthToken();
6661
6799
  if (keys.anthropicApiKey !== "") {
@@ -6678,13 +6816,7 @@ async function resolveSubscriptionKeys(settings, credentials) {
6678
6816
  /** 读本机 OpenCode 客户端的凭据 token;取不到返回空串(安静退回)。 */
6679
6817
  async function readOpenCodeToken() {
6680
6818
  try {
6681
- const auth = JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8"));
6682
- if (typeof auth === "string" && auth !== "") return auth;
6683
- if (auth !== null && typeof auth === "object") {
6684
- const record = auth;
6685
- const token = record.token ?? record.key ?? record.apiKey;
6686
- if (typeof token === "string" && token !== "") return token;
6687
- }
6819
+ return parseOpenCodeAuthDocument(JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8")));
6688
6820
  } catch {}
6689
6821
  return "";
6690
6822
  }
@@ -6964,26 +7096,10 @@ function apply(ctx, config = {}) {
6964
7096
  input: stats.total.input,
6965
7097
  output: stats.total.output
6966
7098
  };
6967
- if (args.range === "bySite" || args.range === "relay") {
6968
- const bySite = stats.bySite ?? {};
6969
- let cost = 0;
6970
- let calls = 0;
6971
- let input = 0;
6972
- let output = 0;
6973
- for (const usage of Object.values(bySite)) {
6974
- cost += usage.cost;
6975
- calls += usage.calls;
6976
- input += usage.input;
6977
- output += usage.output;
6978
- }
6979
- return {
6980
- range: args.range,
6981
- cost,
6982
- calls,
6983
- input,
6984
- output
6985
- };
6986
- }
7099
+ if (args.range === "bySite" || args.range === "relay") return {
7100
+ range: args.range,
7101
+ ...sumSiteBuckets(stats.bySite ?? {}, args.range)
7102
+ };
6987
7103
  if (args.range === "today") {
6988
7104
  const day = stats.byDay[dayStamp(Date.now())];
6989
7105
  return day === void 0 ? zero : {
@@ -7137,7 +7253,8 @@ function apply(ctx, config = {}) {
7137
7253
  ]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
7138
7254
  const official = balances.find((row) => row.provider === "deepseek");
7139
7255
  let reconcile;
7140
- if (official !== void 0 && official.totalBalance !== void 0) {
7256
+ const reconcilable = official !== void 0 && official.totalBalance !== void 0 && (official.currency === void 0 || official.currency.toUpperCase() === "CNY");
7257
+ if (official !== void 0 && reconcilable) {
7141
7258
  const now = Date.now();
7142
7259
  const today = dayStamp(now);
7143
7260
  let todayOfficialCost = 0;
@@ -7145,12 +7262,14 @@ function apply(ctx, config = {}) {
7145
7262
  todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
7146
7263
  } catch {}
7147
7264
  const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
7265
+ const refChanged = result.ref !== null && result.ref !== reconcileRef;
7148
7266
  reconcileRef = result.ref;
7149
7267
  if (result.event !== null && result.event.kind !== "flat") reconcile = {
7150
7268
  ...result.event,
7151
- provider: official.displayName
7269
+ provider: official.displayName,
7270
+ ...official.currency === void 0 ? {} : { currency: official.currency }
7152
7271
  };
7153
- if (result.ref !== null) persistReconcileRef();
7272
+ if (refChanged) persistReconcileRef();
7154
7273
  }
7155
7274
  const doc = {
7156
7275
  balances: [
@@ -7176,17 +7295,17 @@ function apply(ctx, config = {}) {
7176
7295
  path: "/api/billing/usage-tool",
7177
7296
  handler: async (req, res) => {
7178
7297
  if (!guardLoopback(req, res, trustedHosts)) return;
7298
+ if (req.method !== "GET" && req.method !== "POST") {
7299
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7300
+ res.end(JSON.stringify({ error: "method not allowed" }));
7301
+ return;
7302
+ }
7179
7303
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
7180
7304
  const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
7181
7305
  if (req.method === "GET") {
7182
7306
  res.end(JSON.stringify({ enabled }));
7183
7307
  return;
7184
7308
  }
7185
- if (req.method !== "POST") {
7186
- res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
7187
- res.end(JSON.stringify({ error: "method not allowed" }));
7188
- return;
7189
- }
7190
7309
  if (!isLoopbackOrigin(req.headers.origin)) {
7191
7310
  res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
7192
7311
  res.end(JSON.stringify({ error: "forbidden: loopback only" }));
@@ -85,6 +85,22 @@ export declare function siteRefOf(provider: string, routes: Readonly<Record<stri
85
85
  }): SiteRef;
86
86
  /** 站点桶的稳定 key:`site:<origin>` 与 `direct:<provider>` 分开,`unknown` 单一桶。 */
87
87
  export declare function siteBucketKey(ref: SiteRef): string;
88
+ /**
89
+ * 站点桶按范围求和(usage_stats 工具的 bySite/relay 口径):bySite 全量累计;
90
+ * relay 只计中转站桶(`site:<origin>` 键)——`direct:*` 与 `unknown` 不计入,
91
+ * 否则 relay 总额与 all 相同,中转站费用被高估。
92
+ */
93
+ export declare function sumSiteBuckets(bySite: Readonly<Record<string, {
94
+ cost: number;
95
+ calls: number;
96
+ input: number;
97
+ output: number;
98
+ }>>, range: 'bySite' | 'relay'): {
99
+ cost: number;
100
+ calls: number;
101
+ input: number;
102
+ output: number;
103
+ };
88
104
  /** Aggregation tuning options. */
89
105
  export interface AggregateOptions {
90
106
  /** 订阅制 provider id 列表;缺省按订阅卡同款 id 判定(`isSubscriptionProviderId`)。 */
@@ -155,8 +171,11 @@ export declare function emptyUsage(): ModelUsage;
155
171
  * @param subscription - whether the call went through a subscription plan; such calls never cost money.
156
172
  * @param timeMs - the call's wall-clock time (epoch ms); drives peak/off-peak pricing.
157
173
  * @param official - whether the call went through the official DeepSeek channel (vs a third-party relay).
174
+ * @param pricedCost - 调用方预算好的本次费用(同一条消息折叠进多个桶时计价结果相同,
175
+ * 逐桶重算会让最热路径付出 N 倍常数);提供时跳过内部的计价闸门与计算,
176
+ * 调用方保证其已按 `!subscription && isPriced(key)` 判定。
158
177
  */
159
- export declare function foldUsage(acc: ModelUsage, usage: TokenUsage, key: string, subscription: boolean, timeMs: number, official?: boolean): void;
178
+ export declare function foldUsage(acc: ModelUsage, usage: TokenUsage, key: string, subscription: boolean, timeMs: number, official?: boolean, pricedCost?: number): void;
160
179
  /**
161
180
  * 联网搜索辅助请求的单次费用估算默认值(人民币元)。DeepSeek 官方对搜索请求
162
181
  * (web_search 服务端工具注入上下文)照常计费,实测每次约 0.01~0.03 元,取中值;
@@ -478,6 +478,9 @@ export declare function formatUnitPrice(price: number, currency?: 'CNY' | 'USD'
478
478
  * @returns 换算到目标币种的单价;同币种或汇率不可用时原值。
479
479
  */
480
480
  export declare function convertUnitPrice(price: number, native: 'CNY' | 'USD', target: CostCurrency, rate: number): number;
481
+ /** 本地时区日期戳 `YYYY-MM-DD`(与服务端 aggregate.ts 的 dayStamp 同口径);
482
+ * 触发卡/热力图/Token 面板共用一个实现(此前三处各抄一份)。 */
483
+ export declare function localDayStamp(time?: number): string;
481
484
  /** Format a large token count with B/M/K suffix. */
482
485
  export declare function formatTokens(value: number): string;
483
486
  /** Format a percentage. */
@@ -27,12 +27,9 @@ interface SettingsReader {
27
27
  value: unknown;
28
28
  }[];
29
29
  }
30
- /** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
31
- * 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
32
- * 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
33
30
  /**
34
- * 信任主机名归一化:去空白、去端口、转小写,丢弃空项。与请求侧
35
- * `host.split(':')[0].toLowerCase()` 同口径,因此匹配忽略大小写与端口。
31
+ * 信任主机名归一化:去空白、去端口、转小写,丢弃空项(IPv6 经 {@link hostNameOf}
32
+ * 去方括号)。与请求侧 Host 头解析同口径,因此匹配忽略大小写与端口。
36
33
  * @param hosts - 配置里的原始名单。
37
34
  * @returns 归一化后的主机名集合(空集 = 与历史版本行为一致)。
38
35
  */
@@ -31,6 +31,8 @@ export interface ReconcileEvent {
31
31
  kind: ReconcileKind;
32
32
  /** 余额提供商显示名(如 DeepSeek);仅 `ok`/`drift`。 */
33
33
  provider?: string;
34
+ /** 余额币种(CNY / USD);仅 `ok`/`drift` 且上游标注币种时携带,供界面标注金额单位。 */
35
+ currency?: string;
34
36
  /** 按余额差反推的当日消费(CNY/USD 按余额币种);仅 `ok`/`drift`。 */
35
37
  spent?: number;
36
38
  /** 本地账本当日的官方渠道费用;仅 `ok`/`drift`。 */
@@ -16,8 +16,10 @@ import type { SubscriptionPlanConfig, SubscriptionQuota, SubscriptionWindow } fr
16
16
  export interface SubscriptionKeys {
17
17
  /** Kimi For Coding API key。 */
18
18
  kimiApiKey: string;
19
- /** Z.ai API key。 */
19
+ /** Z.ai Coding Plan API key(国际域 api.z.ai)。 */
20
20
  zaiApiKey: string;
21
+ /** Z.ai(智谱国内域)Coding Plan API key——国内 / 国际是两个平台,key 不通用(不同于 MiniMax 的同 key 双域)。 */
22
+ zaiCnApiKey: string;
21
23
  /** OpenCode Go API key。 */
22
24
  opencodeApiKey: string;
23
25
  /** MiniMax Token Plan API key。 */
@@ -33,6 +35,15 @@ export interface SubscriptionKeys {
33
35
  /** Z.ai 区域(global / bigmodel-cn)。 */
34
36
  zaiRegion: 'global' | 'bigmodel-cn';
35
37
  }
38
+ /**
39
+ * 解析 OpenCode 客户端凭据文档(~/.local/share/opencode/auth.json)里的
40
+ * OpenCode Go token。两种形态都收:
41
+ * - 平铺:`{"token": "..."}` / `{"key": "..."}` / `{"apiKey": "..."}` / 裸字符串;
42
+ * - 按 provider 分桶(实测形态):`{"opencode-go": {"type": "api", "key": "..."}}`,
43
+ * 桶序 opencode-go → opencode → zen 即优先序。
44
+ * 识别不出返回空串,绝不抛错——这只是一次便利回退。
45
+ */
46
+ export declare function parseOpenCodeAuthDocument(document: unknown): string;
36
47
  /** 空凭据:全部未配置时的初始值。 */
37
48
  export declare const EMPTY_SUBSCRIPTION_KEYS: SubscriptionKeys;
38
49
  /** 已识别的一个订阅套餐(用户在 llm-pi-ai 里配置了 key 的订阅类 provider)。 */
@@ -43,7 +54,7 @@ export interface IdentifiedSubscriptionPlan {
43
54
  displayName: string;
44
55
  /** 是否有额度查询适配器。 */
45
56
  adapter: boolean;
46
- /** 适配器区域覆盖(zai-coding-cn → bigmodel-cn)。 */
57
+ /** 适配器区域覆盖:两个 Z.ai 路由各自固定区域,互不跟随 keys.zaiRegion。 */
47
58
  region?: 'global' | 'bigmodel-cn';
48
59
  }
49
60
  /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-ui-usage-billing",
3
3
  "description": "Usage billing dashboard for DeepSeek Harness: sidebar cost metrics plus a full dashboard modal, priced from a current multi-provider catalog with real usage aggregated from session logs.",
4
- "version": "1.4.15",
4
+ "version": "1.4.17",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "deepseek",