@kenz1117/dsh-ui-usage-billing 1.4.1 → 1.4.3

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
@@ -317,7 +317,7 @@ function tierAtWithBounds(timeMs, bounds) {
317
317
  }
318
318
  /** 时刻是否落在北京时间周末(周六/周日)。 */
319
319
  function isBeijingWeekend(timeMs) {
320
- const day = new Date(timeMs + 288e5).getUTCDay();
320
+ const day = new Date(timeMs + 8 * 36e5).getUTCDay();
321
321
  return day === 0 || day === 6;
322
322
  }
323
323
  /** 峰谷切换边界(北京时间的当日分钟数):09:00 / 12:00 / 14:00 / 18:00。 */
@@ -329,7 +329,7 @@ const TIER_BOUNDARY_MINUTES = [
329
329
  ];
330
330
  /** 北京时间的当日毫秒数(0–86,400,000)。 */
331
331
  function beijingMillisOfDay(timeMs) {
332
- return ((timeMs + 288e5) % 864e5 + 864e5) % 864e5;
332
+ return ((timeMs + 8 * 36e5) % 864e5 + 864e5) % 864e5;
333
333
  }
334
334
  /**
335
335
  * Built-in catalog of current mainstream models as of 2026-08-16, priced from
@@ -3116,8 +3116,8 @@ function createUsageAggregator(persistence, options = {}) {
3116
3116
  };
3117
3117
  const toModelDayRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries(models)]));
3118
3118
  const toModelDaySiteRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries([...models].map(([model, sites]) => [model, Object.fromEntries(sites)]))]));
3119
- const modelKeys = /* @__PURE__ */ new Set([...perfModel.keys(), ...perfModelDigest.keys()]);
3120
- const hourKeys = /* @__PURE__ */ new Set([...perfHourModel.keys(), ...perfHourDigest.keys()]);
3119
+ const modelKeys = new Set([...perfModel.keys(), ...perfModelDigest.keys()]);
3120
+ const hourKeys = new Set([...perfHourModel.keys(), ...perfHourDigest.keys()]);
3121
3121
  const perf = modelKeys.size === 0 && hourKeys.size === 0 ? void 0 : {
3122
3122
  byModel: Object.fromEntries([...modelKeys].map((model) => {
3123
3123
  const live = perfModel.get(model);
@@ -3128,7 +3128,7 @@ function createUsageAggregator(persistence, options = {}) {
3128
3128
  byHourModel: Object.fromEntries([...hourKeys].sort().map((hour) => {
3129
3129
  const liveModels = perfHourModel.get(hour);
3130
3130
  const digestModels = perfHourDigest.get(hour);
3131
- const modelKeysInHour = /* @__PURE__ */ new Set([...liveModels?.keys() ?? [], ...digestModels?.keys() ?? []]);
3131
+ const modelKeysInHour = new Set([...liveModels?.keys() ?? [], ...digestModels?.keys() ?? []]);
3132
3132
  return [hour, Object.fromEntries([...modelKeysInHour].map((model) => {
3133
3133
  const live = liveModels?.get(model);
3134
3134
  const restored = digestModels?.get(model);
@@ -3331,11 +3331,7 @@ function tc3Authorization(secretId, secretKey, payload, timestamp) {
3331
3331
  const canonicalRequest = `POST\n/\n\n${`content-type:application/json; charset=utf-8\nhost:${TOKENHUB_HOST}\n`}\ncontent-type;host\n${createHash("sha256").update(payload).digest("hex")}`;
3332
3332
  const hashedCanonical = createHash("sha256").update(canonicalRequest).digest("hex");
3333
3333
  const stringToSign = `TC3-HMAC-SHA256\n${String(timestamp)}\n${date}/${TOKENHUB_SERVICE}/tc3_request\n${hashedCanonical}`;
3334
- const kDate = createHmac("sha256", date).update(secretKey).digest();
3335
- const kService = createHmac("sha256", kDate).update(TOKENHUB_SERVICE).digest();
3336
- const kSigning = createHmac("sha256", kService).update("tc3_request").digest();
3337
- const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
3338
- return `TC3-HMAC-SHA256 Credential=${secretId}/${date}/${TOKENHUB_SERVICE}/tc3_request, SignedHeaders=content-type;host, Signature=${signature}`;
3334
+ return `TC3-HMAC-SHA256 Credential=${secretId}/${date}/${TOKENHUB_SERVICE}/tc3_request, SignedHeaders=content-type;host, Signature=${createHmac("sha256", createHmac("sha256", createHmac("sha256", createHmac("sha256", date).update(secretKey).digest()).update(TOKENHUB_SERVICE).digest()).update("tc3_request").digest()).update(stringToSign).digest("hex")}`;
3339
3335
  }
3340
3336
  /**
3341
3337
  * 调用一次 TokenHub 管控面接口:TC3 签名 + 超时保护,返回响应 JSON 的 `Response`。
@@ -4099,7 +4095,7 @@ const declaredGate = createCooldownGate({
4099
4095
  cooldownMs: 6e4
4100
4096
  });
4101
4097
  /** 会触及原型链的路径段:一律拒绝(读结果不是文档自带字段)。 */
4102
- const FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([
4098
+ const FORBIDDEN_SEGMENTS = new Set([
4103
4099
  "__proto__",
4104
4100
  "constructor",
4105
4101
  "prototype"
@@ -4640,6 +4636,29 @@ function buildPrices(models) {
4640
4636
  function asPrice(value) {
4641
4637
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
4642
4638
  }
4639
+ /** models.dev 常把同一模型收录在多个 provider 下(厂商 + 转售)。这些 id 是模型
4640
+ * 自身的厂商,其报价视为权威:转售可能加价,也可能省略 cache_read 而落到下方
4641
+ * `input * 0.1` 的猜测值。去重时优先采用。 */
4642
+ const FIRST_PARTY_PRICE_SOURCES = new Set([
4643
+ "anthropic",
4644
+ "openai",
4645
+ "google",
4646
+ "mistral",
4647
+ "deepseek",
4648
+ "moonshotai",
4649
+ "zhipuai",
4650
+ "alibaba",
4651
+ "alibaba-cn",
4652
+ "xai",
4653
+ "meta",
4654
+ "cohere",
4655
+ "ai21",
4656
+ "minimax",
4657
+ "stepfun",
4658
+ "upstage",
4659
+ "perplexity",
4660
+ "morph"
4661
+ ]);
4643
4662
  /**
4644
4663
  * models.dev 响应 → 目录外补充条目。不再按厂商白名单过滤:凡是有有效
4645
4664
  * cost 的模型都纳入(探活模型可能来自任何预制厂商,白名单会漏掉)。厂商
@@ -4649,12 +4668,16 @@ function asPrice(value) {
4649
4668
  */
4650
4669
  function buildExtraModels(data) {
4651
4670
  if (data === null || typeof data !== "object") return [];
4652
- const catalogKeys = /* @__PURE__ */ new Set([...MODEL_CATALOG.map((entry) => entry.key.toLowerCase()), ...Object.keys(MODEL_KEY_ALIASES).map((key) => MODEL_KEY_ALIASES[key]?.toLowerCase() ?? key.toLowerCase())]);
4653
- const extras = [];
4671
+ const catalogKeys = new Set([...MODEL_CATALOG.map((entry) => entry.key.toLowerCase()), ...Object.keys(MODEL_KEY_ALIASES).map((key) => MODEL_KEY_ALIASES[key]?.toLowerCase() ?? key.toLowerCase())]);
4672
+ const offers = /* @__PURE__ */ new Map();
4673
+ const familyVendorVotes = /* @__PURE__ */ new Map();
4654
4674
  for (const [providerId, providerDoc] of Object.entries(data)) {
4655
4675
  if (providerDoc === null || typeof providerDoc !== "object") continue;
4656
4676
  const models = providerDoc.models;
4657
4677
  if (models === null || typeof models !== "object") continue;
4678
+ const providerKey = providerId.toLowerCase();
4679
+ const providerName = providerDoc.name;
4680
+ const providerDisplay = MODELS_DEV_PROVIDERS[providerKey] ?? (typeof providerName === "string" && providerName !== "" ? providerName : providerId);
4658
4681
  for (const [modelId, modelDoc] of Object.entries(models)) {
4659
4682
  const catalogKey = (MODEL_KEY_ALIASES[modelId] ?? modelId).toLowerCase();
4660
4683
  if (catalogKeys.has(catalogKey)) continue;
@@ -4665,20 +4688,87 @@ function buildExtraModels(data) {
4665
4688
  const input = asPrice(cost.input);
4666
4689
  const output = asPrice(cost.output);
4667
4690
  if (input === void 0 || output === void 0) continue;
4668
- const cacheRead = asPrice(cost.cache_read) ?? input * .1;
4691
+ const declaredCacheRead = asPrice(cost.cache_read);
4692
+ const cacheRead = declaredCacheRead ?? input * .1;
4669
4693
  const name = modelDoc.name;
4670
- extras.push({
4671
- key,
4672
- name: typeof name === "string" && name !== "" ? name : modelId,
4673
- provider: MODELS_DEV_PROVIDERS[providerId.toLowerCase()] ?? providerId,
4674
- price: {
4675
- input,
4676
- cacheHit: cacheRead,
4677
- output
4694
+ const familyValue = modelDoc.family;
4695
+ const family = typeof familyValue === "string" && familyValue !== "" ? familyValue : void 0;
4696
+ const slash = modelId.indexOf("/");
4697
+ const namespace = slash > 0 ? modelId.slice(0, slash).toLowerCase() : "";
4698
+ const firstParty = FIRST_PARTY_PRICE_SOURCES.has(providerKey) || namespace !== "" && namespace === providerKey;
4699
+ if (firstParty && family !== void 0) {
4700
+ let votes = familyVendorVotes.get(family);
4701
+ if (votes === void 0) {
4702
+ votes = /* @__PURE__ */ new Map();
4703
+ familyVendorVotes.set(family, votes);
4678
4704
  }
4705
+ votes.set(providerDisplay, (votes.get(providerDisplay) ?? 0) + 1);
4706
+ }
4707
+ let list = offers.get(key);
4708
+ if (list === void 0) {
4709
+ list = [];
4710
+ offers.set(key, list);
4711
+ }
4712
+ list.push({
4713
+ entry: {
4714
+ key,
4715
+ name: typeof name === "string" && name !== "" ? name : modelId,
4716
+ provider: providerDisplay,
4717
+ price: {
4718
+ input,
4719
+ cacheHit: cacheRead,
4720
+ output
4721
+ }
4722
+ },
4723
+ input,
4724
+ output,
4725
+ pair: `${input}/${output}`,
4726
+ firstParty,
4727
+ declared: declaredCacheRead !== void 0,
4728
+ namespace,
4729
+ ...family === void 0 ? {} : { family },
4730
+ providerDisplay
4679
4731
  });
4680
4732
  }
4681
4733
  }
4734
+ const familyVendor = /* @__PURE__ */ new Map();
4735
+ for (const [family, votes] of familyVendorVotes) {
4736
+ let winner;
4737
+ let winnerVotes = 0;
4738
+ for (const [vendor, count] of votes) if (count > winnerVotes) {
4739
+ winnerVotes = count;
4740
+ winner = vendor;
4741
+ }
4742
+ if (winner !== void 0) familyVendor.set(family, winner);
4743
+ }
4744
+ const extras = [];
4745
+ for (const list of offers.values()) {
4746
+ const firstPartyRow = list.find((candidate) => candidate.firstParty);
4747
+ let pool;
4748
+ if (firstPartyRow !== void 0) pool = list.filter((candidate) => candidate.firstParty);
4749
+ else {
4750
+ const tally = /* @__PURE__ */ new Map();
4751
+ for (const candidate of list) tally.set(candidate.pair, (tally.get(candidate.pair) ?? 0) + 1);
4752
+ let consensus;
4753
+ let consensusCount = 0;
4754
+ for (const [pair, count] of tally) if (count > consensusCount) {
4755
+ consensusCount = count;
4756
+ consensus = pair;
4757
+ }
4758
+ if (consensusCount > 1) pool = list.filter((candidate) => candidate.pair === consensus);
4759
+ else {
4760
+ const sorted = [...list].sort((a, b) => a.input - b.input || a.output - b.output);
4761
+ const median = sorted[Math.floor((sorted.length - 1) / 2)];
4762
+ pool = median === void 0 ? [] : [median];
4763
+ }
4764
+ }
4765
+ const best = pool.find((candidate) => candidate.declared) ?? pool[0];
4766
+ if (best === void 0) continue;
4767
+ const familyName = list.find((candidate) => candidate.family !== void 0)?.family;
4768
+ const learnedVendor = familyName === void 0 ? void 0 : familyVendor.get(familyName);
4769
+ best.entry.provider = learnedVendor ?? (best.firstParty ? best.providerDisplay : best.namespace !== "" ? MODELS_DEV_PROVIDERS[best.namespace] ?? best.namespace : familyName !== void 0 ? familyName.charAt(0).toUpperCase() + familyName.slice(1) : best.providerDisplay);
4770
+ extras.push(best.entry);
4771
+ }
4682
4772
  return extras;
4683
4773
  }
4684
4774
  /**
@@ -5499,7 +5589,7 @@ const relayGate = createCooldownGate({
5499
5589
  /** Abort a relay fetch when the upstream hangs beyond this budget. */
5500
5590
  const FETCH_TIMEOUT_MS = 8e3;
5501
5591
  /** 指纹识别缓存 TTL(毫秒):识别结果低频变化,5 分钟内同 origin 不再重复探测。 */
5502
- const FINGERPRINT_TTL_MS = 3e5;
5592
+ const FINGERPRINT_TTL_MS = 300 * 1e3;
5503
5593
  /** 每 origin 的识别结果缓存:`kind` 是识别出的中转站程序,`at` 是探测时刻。 */
5504
5594
  const fingerprintCache = /* @__PURE__ */ new Map();
5505
5595
  /** Number, or null when the value is not a finite number (nor numeric string). */
@@ -5635,7 +5725,7 @@ function originOf(baseURL) {
5635
5725
  * 子路径只会得到 404/非中转站格式,因而面板应排除它们,避免误判为
5636
5726
  * 「未识别」。中转站面板只列真正的第三方中转程序。
5637
5727
  */
5638
- const OFFICIAL_HOSTS = /* @__PURE__ */ new Set([
5728
+ const OFFICIAL_HOSTS = new Set([
5639
5729
  "api.deepseek.com",
5640
5730
  "api.openai.com",
5641
5731
  "open.bigmodel.cn",
@@ -5839,11 +5929,23 @@ function isLoopbackPeer(req) {
5839
5929
  /** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
5840
5930
  * 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
5841
5931
  * 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
5842
- function isLoopbackHost(req) {
5932
+ /**
5933
+ * 信任主机名归一化:去空白、去端口、转小写,丢弃空项。与请求侧
5934
+ * `host.split(':')[0].toLowerCase()` 同口径,因此匹配忽略大小写与端口。
5935
+ * @param hosts - 配置里的原始名单。
5936
+ * @returns 归一化后的主机名集合(空集 = 与历史版本行为一致)。
5937
+ */
5938
+ function normalizeTrustedHosts(hosts) {
5939
+ if (hosts === void 0) return /* @__PURE__ */ new Set();
5940
+ const names = hosts.filter((host) => typeof host === "string").map((host) => host.trim().split(":")[0]?.toLowerCase() ?? "").filter((host) => host !== "");
5941
+ return new Set(names);
5942
+ }
5943
+ function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
5843
5944
  const host = req.headers.host;
5844
5945
  if (host === void 0 || host === "") return true;
5845
5946
  const name = host.split(":")[0];
5846
- return name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
5947
+ if (name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
5948
+ return name !== void 0 && trustedHosts.has(name.toLowerCase());
5847
5949
  }
5848
5950
  /** 校验 Origin 头是否回环(写操作用,防止跨站表单/fetch 改写设置)。Origin 缺失
5849
5951
  * (curl 或同源 fetch 不带)视为放行,交给下方的 Content-Type 校验兜底;Origin 存在
@@ -5864,10 +5966,15 @@ function isLoopbackOrigin(origin) {
5864
5966
  * @param res - 当前响应。
5865
5967
  * @returns 是否放行;false = 已拒绝并结束响应。
5866
5968
  */
5867
- function guardLoopback(req, res) {
5868
- if (!(req.method === "GET" || req.method === "POST") || !isLoopbackPeer(req) || !isLoopbackHost(req)) {
5969
+ function guardLoopback(req, res, trustedHosts = /* @__PURE__ */ new Set()) {
5970
+ const methodOk = req.method === "GET" || req.method === "POST";
5971
+ const peerOk = isLoopbackPeer(req);
5972
+ const hostOk = isLoopbackHost(req, trustedHosts);
5973
+ if (!methodOk || !peerOk || !hostOk) {
5869
5974
  res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
5870
- res.end(JSON.stringify({ error: "forbidden: loopback only" }));
5975
+ const host = req.headers.host;
5976
+ const hint = methodOk && peerOk && typeof host === "string" && host !== "" ? ` (host "${host.slice(0, 80)}" not in trustedHosts)` : "";
5977
+ res.end(JSON.stringify({ error: `forbidden: loopback only${hint}` }));
5871
5978
  return false;
5872
5979
  }
5873
5980
  return true;
@@ -5888,13 +5995,13 @@ const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESP
5888
5995
  /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
5889
5996
  const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
5890
5997
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
5891
- const PRICING_REFRESH_INTERVAL_MS = 216e5;
5998
+ const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
5892
5999
  /** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
5893
6000
  * 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
5894
6001
  const WARMUP_DELAY_MS = 3e3;
5895
6002
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
5896
- const SUBSCRIPTION_CACHE_MS = 3e5;
5897
- const BALANCE_CACHE_MS = 3e5;
6003
+ const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
6004
+ const BALANCE_CACHE_MS = 300 * 1e3;
5898
6005
  /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
5899
6006
  const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
5900
6007
  /**
@@ -5911,7 +6018,7 @@ const SNAPSHOT_INTERVAL_MS = 3e5;
5911
6018
  * 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
5912
6019
  const RESPONSE_BUDGET_MS = 1500;
5913
6020
  /** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
5914
- const AUTH_WARN_COOLDOWN_MS = 18e5;
6021
+ const AUTH_WARN_COOLDOWN_MS = 1800 * 1e3;
5915
6022
  /**
5916
6023
  * 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
5917
6024
  * `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
@@ -6243,6 +6350,7 @@ function adaptSessionPersistence(raw) {
6243
6350
  * @param config - optional statsPath override.
6244
6351
  */
6245
6352
  function apply(ctx, config = {}) {
6353
+ const trustedHosts = normalizeTrustedHosts(config.trustedHosts);
6246
6354
  let usageSettingsScope;
6247
6355
  const cwd = process.cwd();
6248
6356
  const dshHome = resolveDshHome();
@@ -6261,8 +6369,7 @@ function apply(ctx, config = {}) {
6261
6369
  })();
6262
6370
  const persistReconcileRef = () => {
6263
6371
  if (reconcileRef === null) return;
6264
- const payload = JSON.stringify({ ref: reconcileRef });
6265
- writeFileAtomic(reconcilePath, payload, {
6372
+ writeFileAtomic(reconcilePath, JSON.stringify({ ref: reconcileRef }), {
6266
6373
  mode: 384,
6267
6374
  dirMode: 448
6268
6375
  }).catch(() => {});
@@ -6280,8 +6387,9 @@ function apply(ctx, config = {}) {
6280
6387
  ctx.effect(() => () => {
6281
6388
  aggregator.flush();
6282
6389
  }, "usage-billing: ledger flush on dispose");
6390
+ let pricingReady;
6283
6391
  const warmupTimer = setTimeout(() => {
6284
- aggregator.aggregate().then(() => {
6392
+ Promise.resolve(pricingReady).catch(() => {}).then(() => aggregator.aggregate()).then(() => {
6285
6393
  console.info("[usage-billing] historical replay warmed up; ledger ready");
6286
6394
  }).catch((error) => {
6287
6395
  console.warn("[usage-billing] historical replay warmup failed; will fold on first dashboard request:", error);
@@ -6334,7 +6442,7 @@ function apply(ctx, config = {}) {
6334
6442
  kind: "exact",
6335
6443
  path: "/api/billing/notify-claim",
6336
6444
  handler: async (req, res) => {
6337
- if (!guardLoopback(req, res)) return;
6445
+ if (!guardLoopback(req, res, trustedHosts)) return;
6338
6446
  if (req.method !== "POST") {
6339
6447
  res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
6340
6448
  res.end(JSON.stringify({ error: "method not allowed" }));
@@ -6544,7 +6652,7 @@ function apply(ctx, config = {}) {
6544
6652
  pricingSyncedAt = Date.now();
6545
6653
  applyLivePricing(live);
6546
6654
  };
6547
- refreshPricing();
6655
+ pricingReady = refreshPricing();
6548
6656
  ctx.effect(() => {
6549
6657
  const timer = setInterval(() => {
6550
6658
  refreshPricing();
@@ -6557,7 +6665,7 @@ function apply(ctx, config = {}) {
6557
6665
  kind: "exact",
6558
6666
  path: "/api/billing/pricing",
6559
6667
  handler: async (req, res) => {
6560
- if (!guardLoopback(req, res)) return;
6668
+ if (!guardLoopback(req, res, trustedHosts)) return;
6561
6669
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6562
6670
  res.end(JSON.stringify({
6563
6671
  ...live,
@@ -6569,7 +6677,7 @@ function apply(ctx, config = {}) {
6569
6677
  kind: "exact",
6570
6678
  path: "/api/billing/pricing/refresh",
6571
6679
  handler: async (req, res) => {
6572
- if (!guardLoopback(req, res)) return;
6680
+ if (!guardLoopback(req, res, trustedHosts)) return;
6573
6681
  if (req.method !== "POST") {
6574
6682
  res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
6575
6683
  res.end(JSON.stringify({ error: "method not allowed" }));
@@ -6601,7 +6709,7 @@ function apply(ctx, config = {}) {
6601
6709
  kind: "exact",
6602
6710
  path: "/api/billing/balance",
6603
6711
  handler: async (req, res) => {
6604
- if (!guardLoopback(req, res)) return;
6712
+ if (!guardLoopback(req, res, trustedHosts)) return;
6605
6713
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6606
6714
  const piProviders = await readPiAiProviders(ctx.settings);
6607
6715
  const providers = { ...piProviders };
@@ -6663,7 +6771,7 @@ function apply(ctx, config = {}) {
6663
6771
  kind: "exact",
6664
6772
  path: "/api/billing/usage-tool",
6665
6773
  handler: async (req, res) => {
6666
- if (!guardLoopback(req, res)) return;
6774
+ if (!guardLoopback(req, res, trustedHosts)) return;
6667
6775
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6668
6776
  const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
6669
6777
  if (req.method === "GET") {
@@ -6750,7 +6858,7 @@ function apply(ctx, config = {}) {
6750
6858
  kind: "exact",
6751
6859
  path: "/api/billing/subscriptions",
6752
6860
  handler: async (req, res) => {
6753
- if (!guardLoopback(req, res)) return;
6861
+ if (!guardLoopback(req, res, trustedHosts)) return;
6754
6862
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6755
6863
  if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
6756
6864
  await refreshQuotas();
@@ -6786,7 +6894,7 @@ function apply(ctx, config = {}) {
6786
6894
  kind: "exact",
6787
6895
  path: "/api/billing/relay-quotas",
6788
6896
  handler: async (req, res) => {
6789
- if (!guardLoopback(req, res)) return;
6897
+ if (!guardLoopback(req, res, trustedHosts)) return;
6790
6898
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6791
6899
  if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
6792
6900
  await refreshRelay();
@@ -6808,7 +6916,7 @@ function apply(ctx, config = {}) {
6808
6916
  kind: "exact",
6809
6917
  path: "/api/billing/usage-stats",
6810
6918
  handler: async (req, res) => {
6811
- if (!guardLoopback(req, res)) return;
6919
+ if (!guardLoopback(req, res, trustedHosts)) return;
6812
6920
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6813
6921
  const decorate = (doc) => ({
6814
6922
  ...doc,
@@ -6845,4 +6953,4 @@ function apply(ctx, config = {}) {
6845
6953
  }), "usage-billing: usage-stats route");
6846
6954
  }
6847
6955
  //#endregion
6848
- export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };
6956
+ export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, inject, normalizeTrustedHosts, readPiAiProviderRoutes, resolveSubscriptionKeys };
@@ -6,7 +6,7 @@
6
6
  * client 半区在「设置」Tab 渲染开关并写入同一命名空间。缺省的默认行为是关闭——
7
7
  * 避免该工具默认占用模型每次请求的上下文(coding 场景通常在仪表盘看用量)。
8
8
  */
9
- import type { UserPriceEntry } from './pricing.ts';
9
+ import type { CostCurrency, UserPriceEntry } from './pricing.ts';
10
10
  /** 设置命名空间 id(小写 kebab-case)。 */
11
11
  export declare const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
12
12
  /** 该命名空间下用户可编辑的字段名。 */
@@ -107,6 +107,20 @@ export declare const LIVE_COST_BAR_PREF_EVENT = "dsh.ui-usage-billing.livecost-p
107
107
  export declare function loadLiveCostBarPrefs(): LiveCostBarPrefs;
108
108
  /** 写入即时代费条偏好。失败静默(展示偏好非关键)。 */
109
109
  export declare function saveLiveCostBarPrefs(prefs: LiveCostBarPrefs): void;
110
+ /**
111
+ * 显示币种(¥ / ≈$)。纯 client 偏好,存 localStorage;仪表盘、侧边栏卡片与
112
+ * 输入框胶囊分属不同 React 树,跨树同步走 localStorage + `CURRENCY_PREF_EVENT`
113
+ * CustomEvent(同文档即时生效,跨标签页靠 storage 事件)——与即时代费条偏好同一套做法。
114
+ */
115
+ export declare const CURRENCY_STORAGE_KEY = "dsh.ui-usage-billing.currency";
116
+ /** 币种切换后派发的 CustomEvent 名(另一棵树监听它即时重读)。 */
117
+ export declare const CURRENCY_PREF_EVENT = "dsh.ui-usage-billing.currency-pref";
118
+ /** 默认币种:人民币(保持历史行为)。 */
119
+ export declare const DEFAULT_CURRENCY: CostCurrency;
120
+ /** 读取显示币种(损坏/缺失/非法值一律回退默认)。仅在浏览器半区调用。 */
121
+ export declare function loadCurrency(): CostCurrency;
122
+ /** 写入显示币种。失败静默(展示偏好非关键)。 */
123
+ export declare function saveCurrency(currency: CostCurrency): void;
110
124
  /** 用户自定义单价(与 client/pricing.ts 的 `UserPriceEntry` 同形;type import,无运行时依赖)。 */
111
125
  export type StoredUserPrice = UserPriceEntry;
112
126
  /** 自定义价表:模型(+可选来源)→ 单价,条目列表(支持同名模型绑定不同中转站 origin)。 */
@@ -19,6 +19,16 @@ import type { SettingsProvider } from '@deepseek-ai/dsh-settings';
19
19
  import { type UsageLedgerStore, type UsagePersistence } from './aggregate.ts';
20
20
  import type { CustomBalanceConfig, DeclaredEndpointConfig, SubscriptionPlanConfig } from './pricing-shared.ts';
21
21
  import { type IdentifiedSubscriptionPlan, type SubscriptionKeys } from './subscriptions.ts';
22
+ /** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
23
+ * 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
24
+ * 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
25
+ /**
26
+ * 信任主机名归一化:去空白、去端口、转小写,丢弃空项。与请求侧
27
+ * `host.split(':')[0].toLowerCase()` 同口径,因此匹配忽略大小写与端口。
28
+ * @param hosts - 配置里的原始名单。
29
+ * @returns 归一化后的主机名集合(空集 = 与历史版本行为一致)。
30
+ */
31
+ export declare function normalizeTrustedHosts(hosts: readonly string[] | undefined): ReadonlySet<string>;
22
32
  /**
23
33
  * 回环防护守卫:仅接受回环 GET 请求(peer socket 地址 + Host 头同时校验)。
24
34
  * 不满足时返回 403 并结束响应;调用方在 handler 顶部调用,返回 false 即已拒绝。
@@ -26,7 +36,7 @@ import { type IdentifiedSubscriptionPlan, type SubscriptionKeys } from './subscr
26
36
  * @param res - 当前响应。
27
37
  * @returns 是否放行;false = 已拒绝并结束响应。
28
38
  */
29
- export declare function guardLoopback(req: IncomingMessage, res: ServerResponse): boolean;
39
+ export declare function guardLoopback(req: IncomingMessage, res: ServerResponse, trustedHosts?: ReadonlySet<string>): boolean;
30
40
  /** Plugin configuration. */
31
41
  export interface UsageBillingConfig {
32
42
  /** Absolute path to a `.dsh-usage-stats.json` fallback file. */
@@ -60,6 +70,14 @@ export interface UsageBillingConfig {
60
70
  * 写死在 declarative.ts。缺省空。
61
71
  */
62
72
  declaredEndpoints?: readonly DeclaredEndpointConfig[];
73
+ /**
74
+ * 允许通过 Host 头校验的额外主机名(反向代理场景);缺省空,行为与历史版本完全一致。
75
+ * 仅在 peer socket 已通过回环校验后才参考:socket 校验仍为强制且不受本字段影响,
76
+ * 本名单只放宽「纵深防御」的第二层。匹配为**精确主机名**,忽略大小写与端口;
77
+ * 不支持后缀 / 通配符,因此 `evil.com` 永远无法满足 `trusted.com`。
78
+ * 例:`['llm.example.com']`。
79
+ */
80
+ trustedHosts?: string[];
63
81
  /** `usage_stats` 工具注入的组合 base(默认 false:不注入);与设置命名空间同字段,
64
82
  * 作为用户设置(设置 Tab 开关)的组合兜底。该工具占用每次请求的上下文,coding 场景多在仪表盘查看。 */
65
83
  enableUsageStatsTool?: boolean;
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.1",
4
+ "version": "1.4.3",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "deepseek",
@@ -66,7 +66,8 @@
66
66
  "0.1.5-alpha.2": "compatible",
67
67
  "0.1.5-rc.1": "compatible",
68
68
  "0.1.5-rc.2": "compatible",
69
- "0.1.6-alpha.1": "compatible"
69
+ "0.1.6-alpha.1": "compatible",
70
+ "0.1.6-alpha.2": "compatible"
70
71
  }
71
72
  }
72
73
  },