@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/README.en.md +6 -5
- package/README.md +5 -4
- package/lib/client.js +5 -5
- package/lib/index.js +154 -46
- package/lib/types/client/usage-billing-settings.d.ts +15 -1
- package/lib/types/index.d.ts +19 -1
- package/package.json +3 -2
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 +
|
|
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 +
|
|
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 =
|
|
3120
|
-
const hourKeys =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
4653
|
-
const
|
|
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
|
|
4691
|
+
const declaredCacheRead = asPrice(cost.cache_read);
|
|
4692
|
+
const cacheRead = declaredCacheRead ?? input * .1;
|
|
4669
4693
|
const name = modelDoc.name;
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
5897
|
-
const BALANCE_CACHE_MS =
|
|
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 =
|
|
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
|
-
|
|
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)。 */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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
|
},
|