@kenz1117/dsh-ui-usage-billing 0.9.18 → 0.9.20
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 +63 -31
- package/README.md +62 -32
- package/lib/client.js +461 -415
- package/lib/index.js +184 -61
- package/lib/types/client/TokenPanel.d.ts +0 -2
- package/lib/types/client/UsageBilling.d.ts +1 -0
- package/lib/types/client/pricing.d.ts +3 -2
- package/lib/types/client/usage-billing-settings.d.ts +2 -1
- package/package.json +28 -28
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
-
import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
6
|
+
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
7
7
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
8
8
|
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
9
9
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -68,6 +68,7 @@ const PLAN_KNOWLEDGE = {
|
|
|
68
68
|
"ark-token-plan": { type: "code" },
|
|
69
69
|
"doubao-token-plan": { type: "code" },
|
|
70
70
|
"minimax": { type: "code" },
|
|
71
|
+
"minimax-cn": { type: "code" },
|
|
71
72
|
"minimax-token-plan": { type: "code" },
|
|
72
73
|
"minimax-token-plan-cn": { type: "code" }
|
|
73
74
|
};
|
|
@@ -183,7 +184,7 @@ let liveExtraModels;
|
|
|
183
184
|
* @param pricing - the `/api/billing/pricing` response.
|
|
184
185
|
*/
|
|
185
186
|
function applyLivePricing(pricing) {
|
|
186
|
-
liveRate = pricing.rate;
|
|
187
|
+
liveRate = typeof pricing.rate === "number" && Number.isFinite(pricing.rate) && pricing.rate > 0 ? pricing.rate : void 0;
|
|
187
188
|
livePrices = pricing.prices;
|
|
188
189
|
liveExtraModels = pricing.extraModels;
|
|
189
190
|
}
|
|
@@ -741,7 +742,10 @@ const MODEL_KEY_ALIASES = {
|
|
|
741
742
|
"step-3.7-flash": "step",
|
|
742
743
|
"seed-2.0-mini": "doubao-mini",
|
|
743
744
|
"k3": "kimi-k3",
|
|
744
|
-
"kimi-k3": "kimi-k3"
|
|
745
|
+
"kimi-k3": "kimi-k3",
|
|
746
|
+
"minimax-m1": "minimax",
|
|
747
|
+
"minimax-m2": "minimax",
|
|
748
|
+
"minimax-m3": "minimax"
|
|
745
749
|
};
|
|
746
750
|
/**
|
|
747
751
|
* 模型 id 归一化:小写、去括号附注(如 `gpt5.6 luna(go)` 只看主体)、再去所有
|
|
@@ -891,7 +895,7 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
|
|
|
891
895
|
* @param buckets - token usage counts.
|
|
892
896
|
* @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
|
|
893
897
|
* @param peakShare - fallback mix used only when `timeMs` is missing.
|
|
894
|
-
* @returns the estimated cost in
|
|
898
|
+
* @returns the estimated cost in CNY(USD 计价模型已按当前汇率折算)。
|
|
895
899
|
*/
|
|
896
900
|
function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
|
|
897
901
|
if (entry.price.offPeak === void 0) return priceBandCost(entry.price, buckets, entry.price.currency);
|
|
@@ -1456,11 +1460,12 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1456
1460
|
console.warn("[usage-billing] failed to load durable usage ledger; rebuilding from current sessions:", error);
|
|
1457
1461
|
}
|
|
1458
1462
|
};
|
|
1459
|
-
/** 失效键:日志文件的 mtime+size;拿不到(后端无 locate /
|
|
1463
|
+
/** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失 / locate 抛错)时返回 null,
|
|
1464
|
+
* 让调用方每次重折。locate 调用也纳入 try,避免单个会话的 locate 异常把整份聚合拖垮。 */
|
|
1460
1465
|
const stampOf = async (meta) => {
|
|
1461
|
-
const location = persistence.locate?.(meta);
|
|
1462
|
-
if (location === void 0) return null;
|
|
1463
1466
|
try {
|
|
1467
|
+
const location = persistence.locate?.(meta);
|
|
1468
|
+
if (location === void 0) return null;
|
|
1464
1469
|
const info = await stat(location.path);
|
|
1465
1470
|
return `${String(info.mtimeMs)}:${String(info.size)}`;
|
|
1466
1471
|
} catch {
|
|
@@ -2080,9 +2085,20 @@ const QUERIERS = [
|
|
|
2080
2085
|
* @returns the balance rows (one per provider).
|
|
2081
2086
|
*/
|
|
2082
2087
|
async function queryBalances(ctx, providers) {
|
|
2083
|
-
|
|
2088
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2089
|
+
for (const { route, querier, displayName } of QUERIERS) {
|
|
2084
2090
|
const env = providers[route]?.apiKeyEnv;
|
|
2085
|
-
|
|
2091
|
+
const configured = typeof env === "string" && env !== "";
|
|
2092
|
+
const existing = byName.get(displayName);
|
|
2093
|
+
if (existing !== void 0 && existing.env !== void 0) continue;
|
|
2094
|
+
byName.set(displayName, {
|
|
2095
|
+
displayName,
|
|
2096
|
+
querier,
|
|
2097
|
+
env: configured ? env : void 0
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
return await Promise.all([...byName.values()].map(({ querier, displayName, env }) => {
|
|
2101
|
+
if (env === void 0) return Promise.resolve({
|
|
2086
2102
|
provider: displayName,
|
|
2087
2103
|
displayName,
|
|
2088
2104
|
error: "unconfigured"
|
|
@@ -2919,10 +2935,11 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
|
|
|
2919
2935
|
"minimax": "MiniMax Coding Plan",
|
|
2920
2936
|
"minimax-token-plan": "MiniMax Token Plan",
|
|
2921
2937
|
"minimax-token-plan-cn": "MiniMax Token Plan(国内)",
|
|
2938
|
+
"minimax-cn": "MiniMax Token Plan(国内)",
|
|
2922
2939
|
"openrouter": "OpenRouter"
|
|
2923
2940
|
};
|
|
2924
2941
|
/** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
|
|
2925
|
-
const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-token-plan|minimax-token-plan-cn|openrouter)", "i");
|
|
2942
|
+
const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-cn|minimax-token-plan|minimax-token-plan-cn|openrouter)", "i");
|
|
2926
2943
|
/** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
|
|
2927
2944
|
function isSubscriptionProviderId(providerId) {
|
|
2928
2945
|
if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
|
|
@@ -2935,6 +2952,7 @@ const SUBSCRIPTION_ADAPTERS = {
|
|
|
2935
2952
|
"opencode": { collect: collectOpenCodeGo },
|
|
2936
2953
|
"opencode-go": { collect: collectOpenCodeGo },
|
|
2937
2954
|
"minimax": { collect: collectMiniMax },
|
|
2955
|
+
"minimax-cn": { collect: collectMiniMax },
|
|
2938
2956
|
"minimax-token-plan": { collect: collectMiniMax },
|
|
2939
2957
|
"minimax-token-plan-cn": { collect: collectMiniMax },
|
|
2940
2958
|
"openrouter": { collect: collectOpenRouter }
|
|
@@ -3210,15 +3228,16 @@ async function collectZai(keys, config, timeoutMs) {
|
|
|
3210
3228
|
function goWindow(value, kind) {
|
|
3211
3229
|
if (value === null || typeof value !== "object") return null;
|
|
3212
3230
|
const record = value;
|
|
3213
|
-
const
|
|
3214
|
-
|
|
3231
|
+
const ratioSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage;
|
|
3232
|
+
const fromRatioField = ratioSource !== void 0 && ratioSource !== null;
|
|
3233
|
+
let usedPercent = clampPercent$1(numberOrNull$1(fromRatioField ? ratioSource : record.percent));
|
|
3215
3234
|
if (usedPercent === null) {
|
|
3216
3235
|
const used = numberOrNull$1(record.used ?? record.consumed);
|
|
3217
3236
|
const limit = numberOrNull$1(record.limit ?? record.total ?? record.quota);
|
|
3218
3237
|
if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent$1(used / limit * 100);
|
|
3219
3238
|
}
|
|
3220
3239
|
if (usedPercent === null) return null;
|
|
3221
|
-
if (usedPercent <= 1 && usedPercent >= 0
|
|
3240
|
+
if (fromRatioField && usedPercent <= 1 && usedPercent >= 0) usedPercent *= 100;
|
|
3222
3241
|
const resetSeconds = numberOrNull$1(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
|
|
3223
3242
|
const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
|
|
3224
3243
|
return {
|
|
@@ -3320,12 +3339,24 @@ function parseMiniMaxRemains(body) {
|
|
|
3320
3339
|
*/
|
|
3321
3340
|
function resolveMiniMaxBaseUrl(config) {
|
|
3322
3341
|
if (typeof config.baseUrl === "string" && config.baseUrl.trim() !== "") return config.baseUrl;
|
|
3323
|
-
return config.provider === "minimax-token-plan-cn" ? "https://api.minimaxi.com" : "https://www.minimaxi.com";
|
|
3342
|
+
return config.provider === "minimax-cn" || config.provider === "minimax-token-plan-cn" ? "https://api.minimaxi.com" : "https://www.minimaxi.com";
|
|
3324
3343
|
}
|
|
3325
3344
|
/** Display name for a MiniMax quota row, aligned with the display-name map. */
|
|
3326
3345
|
function minmaxDisplayName(provider) {
|
|
3327
3346
|
return SUBSCRIPTION_DISPLAY_NAMES[provider] ?? "MiniMax Coding Plan";
|
|
3328
3347
|
}
|
|
3348
|
+
/** MiniMax Token Plan / Coding Plan 的 key 判别:普通按量 API key 以 `sk-` 开头,
|
|
3349
|
+
* Token Plan(订阅套餐)key 以 `sk-cp-` 开头。据此区分一个 key 是订阅还是按量,
|
|
3350
|
+
* 供查询失败时给出「key 类型 / region」定向提示,避免把按量 key 误当 token-plan
|
|
3351
|
+
* 查询后只剩一句笼统的「响应异常」。 */
|
|
3352
|
+
function isMiniMaxTokenPlanKey(apiKey) {
|
|
3353
|
+
return apiKey.startsWith("sk-cp-");
|
|
3354
|
+
}
|
|
3355
|
+
/** 构造 MiniMax Token Plan 查询端点:避免 base 已含 `/v1` 时拼出 `/v1/v1/...`。 */
|
|
3356
|
+
function miniMaxEndpoint(base) {
|
|
3357
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
3358
|
+
return trimmed.endsWith("/v1") ? `${trimmed}/token_plan/remains` : `${trimmed}/v1/token_plan/remains`;
|
|
3359
|
+
}
|
|
3329
3360
|
/** Collect the MiniMax Token Plan quota (CN + INTL). */
|
|
3330
3361
|
async function collectMiniMax(keys, config, timeoutMs) {
|
|
3331
3362
|
const apiKey = keys.minmaxApiKey.trim();
|
|
@@ -3337,16 +3368,25 @@ async function collectMiniMax(keys, config, timeoutMs) {
|
|
|
3337
3368
|
status: "not-configured",
|
|
3338
3369
|
windows: []
|
|
3339
3370
|
};
|
|
3371
|
+
const tokenPlanKey = isMiniMaxTokenPlanKey(apiKey);
|
|
3340
3372
|
try {
|
|
3341
|
-
const windows = parseMiniMaxRemains(await requestJson(
|
|
3373
|
+
const windows = parseMiniMaxRemains(await requestJson(miniMaxEndpoint(base), { headers: {
|
|
3342
3374
|
authorization: `Bearer ${apiKey}`,
|
|
3343
3375
|
accept: "application/json"
|
|
3344
3376
|
} }, timeoutMs));
|
|
3377
|
+
if (windows.length > 0) return {
|
|
3378
|
+
provider: config.provider,
|
|
3379
|
+
displayName,
|
|
3380
|
+
status: "ok",
|
|
3381
|
+
windows,
|
|
3382
|
+
...tokenPlanKey ? {} : { hint: "此 key 未以 sk-cp- 开头(可能为按量 API);但 Token Plan 接口返回了额度,故按 Token Plan 展示" }
|
|
3383
|
+
};
|
|
3345
3384
|
return {
|
|
3346
3385
|
provider: config.provider,
|
|
3347
3386
|
displayName,
|
|
3348
|
-
status:
|
|
3349
|
-
windows
|
|
3387
|
+
status: "invalid-response",
|
|
3388
|
+
windows,
|
|
3389
|
+
hint: tokenPlanKey ? "Token Plan key 未返回额度:可能是 region 不匹配,请在 provider 的 baseURL 指向国内 api.minimaxi.com 或国际 api.minimax.io" : "此 key 未以 sk-cp- 开头,可能为按量 API 而非 Token Plan;如需订阅额度请用 sk-cp- 开头的 Token Plan key"
|
|
3350
3390
|
};
|
|
3351
3391
|
} catch (error) {
|
|
3352
3392
|
return {
|
|
@@ -3625,6 +3665,19 @@ function endpointOf(baseURL, path) {
|
|
|
3625
3665
|
return new URL(path, baseURL).toString();
|
|
3626
3666
|
}
|
|
3627
3667
|
/**
|
|
3668
|
+
* 用已知程序类型查询该路由的额度(用当前 key)。额度按 key 独立,因此即便
|
|
3669
|
+
* 指纹缓存已识别出站点类型,也仍要实际发一次请求读出本 key 的余额/窗口。
|
|
3670
|
+
* @param baseURL - 站点端点。
|
|
3671
|
+
* @param apiKey - 已解析的本路由 key。
|
|
3672
|
+
* @param kind - 已识别出的程序类型。
|
|
3673
|
+
* @returns 解析出的余额/窗口;取不到时返回 null(漂移或端点不可用)。
|
|
3674
|
+
*/
|
|
3675
|
+
async function readRelayByKind(baseURL, apiKey, kind) {
|
|
3676
|
+
const res = await fetchRelayJson(endpointOf(baseURL, kind === "sub2api" ? "/v1/usage" : "/api/status"), apiKey);
|
|
3677
|
+
if (!res.ok) return null;
|
|
3678
|
+
return kind === "sub2api" ? parseSub2ApiUsage(res.data) : parseNewApiStatus(res.data);
|
|
3679
|
+
}
|
|
3680
|
+
/**
|
|
3628
3681
|
* 查询单个中转站路由的额度。先试 Sub2API,再试 New API;任一读出额度即返回。
|
|
3629
3682
|
* @param ctx - host context carrying the credentials seam.
|
|
3630
3683
|
* @param route - 待探测的路由(baseURL + apiKeyEnv)。
|
|
@@ -3642,19 +3695,43 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3642
3695
|
kind: "unknown",
|
|
3643
3696
|
status: "unavailable"
|
|
3644
3697
|
};
|
|
3645
|
-
|
|
3698
|
+
let hit;
|
|
3699
|
+
try {
|
|
3700
|
+
hit = await ctx.credentials.resolve(credentialRef(route.apiKeyEnv));
|
|
3701
|
+
} catch {
|
|
3702
|
+
return {
|
|
3703
|
+
...base,
|
|
3704
|
+
kind: "unknown",
|
|
3705
|
+
status: "not-configured"
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3646
3708
|
if (hit === void 0 || hit.value === "") return {
|
|
3647
3709
|
...base,
|
|
3648
3710
|
kind: "unknown",
|
|
3649
3711
|
status: "not-configured"
|
|
3650
3712
|
};
|
|
3651
3713
|
const cached = fingerprintCache.get(base.origin);
|
|
3652
|
-
|
|
3653
|
-
...base,
|
|
3654
|
-
kind: cached.kind,
|
|
3655
|
-
status: "ok"
|
|
3656
|
-
};
|
|
3714
|
+
const knownKind = cached !== void 0 && Date.now() - cached.at < FINGERPRINT_TTL_MS ? cached.kind : void 0;
|
|
3657
3715
|
try {
|
|
3716
|
+
if (knownKind === "sub2api" || knownKind === "new-api") {
|
|
3717
|
+
const parsed = await readRelayByKind(route.baseURL, hit.value, knownKind);
|
|
3718
|
+
if (parsed !== null) {
|
|
3719
|
+
relayGate.success(gateKey);
|
|
3720
|
+
return {
|
|
3721
|
+
...base,
|
|
3722
|
+
kind: knownKind,
|
|
3723
|
+
status: "ok",
|
|
3724
|
+
...parsed.balance !== void 0 ? { balance: parsed.balance } : {},
|
|
3725
|
+
...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3728
|
+
relayGate.fail(gateKey);
|
|
3729
|
+
return {
|
|
3730
|
+
...base,
|
|
3731
|
+
kind: knownKind,
|
|
3732
|
+
status: "invalid-response"
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
3658
3735
|
const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
|
|
3659
3736
|
if (sub2.ok) {
|
|
3660
3737
|
const parsed = parseSub2ApiUsage(sub2.data);
|
|
@@ -3679,23 +3756,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3679
3756
|
status: "invalid-response"
|
|
3680
3757
|
};
|
|
3681
3758
|
}
|
|
3682
|
-
if (sub2.status === 401 || sub2.status === 403) {
|
|
3683
|
-
relayGate.fail(gateKey);
|
|
3684
|
-
return {
|
|
3685
|
-
...base,
|
|
3686
|
-
kind: "unknown",
|
|
3687
|
-
status: "unauthorized"
|
|
3688
|
-
};
|
|
3689
|
-
}
|
|
3690
3759
|
const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
|
|
3691
|
-
if (na.status === 401 || na.status === 403) {
|
|
3692
|
-
relayGate.fail(gateKey);
|
|
3693
|
-
return {
|
|
3694
|
-
...base,
|
|
3695
|
-
kind: "unknown",
|
|
3696
|
-
status: "unauthorized"
|
|
3697
|
-
};
|
|
3698
|
-
}
|
|
3699
3760
|
if (na.ok) {
|
|
3700
3761
|
const parsed = parseNewApiStatus(na.data);
|
|
3701
3762
|
if (parsed !== null) {
|
|
@@ -3725,11 +3786,12 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3725
3786
|
status: "unavailable"
|
|
3726
3787
|
};
|
|
3727
3788
|
} catch (error) {
|
|
3728
|
-
|
|
3789
|
+
const status = statusOf(error);
|
|
3790
|
+
if (status !== "unauthorized") relayGate.fail(gateKey);
|
|
3729
3791
|
return {
|
|
3730
3792
|
...base,
|
|
3731
3793
|
kind: "unknown",
|
|
3732
|
-
status
|
|
3794
|
+
status
|
|
3733
3795
|
};
|
|
3734
3796
|
}
|
|
3735
3797
|
}
|
|
@@ -3776,12 +3838,26 @@ function isLoopbackPeer(req) {
|
|
|
3776
3838
|
if (address === void 0) return false;
|
|
3777
3839
|
return address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
|
|
3778
3840
|
}
|
|
3779
|
-
/** 校验 Host
|
|
3841
|
+
/** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
|
|
3842
|
+
* 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
|
|
3843
|
+
* 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
|
|
3780
3844
|
function isLoopbackHost(req) {
|
|
3781
3845
|
const host = req.headers.host;
|
|
3782
3846
|
if (host === void 0 || host === "") return true;
|
|
3783
3847
|
const name = host.split(":")[0];
|
|
3784
|
-
return name === "localhost" || name === "::1" || name !== void 0 && name
|
|
3848
|
+
return name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
|
|
3849
|
+
}
|
|
3850
|
+
/** 校验 Origin 头是否回环(写操作用,防止跨站表单/fetch 改写设置)。Origin 缺失
|
|
3851
|
+
* (curl 或同源 fetch 不带)视为放行,交给下方的 Content-Type 校验兜底;Origin 存在
|
|
3852
|
+
* 但主机非回环则拒绝——跨站脚本发起的写请求必带攻击者域名的 Origin。 */
|
|
3853
|
+
function isLoopbackOrigin(origin) {
|
|
3854
|
+
if (origin === void 0 || origin === "") return true;
|
|
3855
|
+
try {
|
|
3856
|
+
const host = new URL(origin).hostname;
|
|
3857
|
+
return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
3858
|
+
} catch {
|
|
3859
|
+
return false;
|
|
3860
|
+
}
|
|
3785
3861
|
}
|
|
3786
3862
|
/**
|
|
3787
3863
|
* 回环防护守卫:仅接受回环 GET 请求(peer socket 地址 + Host 头同时校验)。
|
|
@@ -3846,17 +3922,23 @@ function createFileUsageLedgerStore(ledgerPath) {
|
|
|
3846
3922
|
} catch {}
|
|
3847
3923
|
},
|
|
3848
3924
|
async save(document) {
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3925
|
+
await mkdir(dirname(ledgerPath), {
|
|
3926
|
+
recursive: true,
|
|
3927
|
+
mode: 448
|
|
3928
|
+
});
|
|
3929
|
+
await withFileLock(ledgerPath, async () => {
|
|
3930
|
+
try {
|
|
3931
|
+
const existing = await readFile(ledgerPath, "utf8");
|
|
3932
|
+
JSON.parse(existing);
|
|
3933
|
+
await writeFileAtomic(`${ledgerPath}.bak`, existing, {
|
|
3934
|
+
mode: 384,
|
|
3935
|
+
dirMode: 448
|
|
3936
|
+
});
|
|
3937
|
+
} catch {}
|
|
3938
|
+
await writeFileAtomic(ledgerPath, JSON.stringify(document), {
|
|
3853
3939
|
mode: 384,
|
|
3854
3940
|
dirMode: 448
|
|
3855
3941
|
});
|
|
3856
|
-
} catch {}
|
|
3857
|
-
await writeFileAtomic(ledgerPath, JSON.stringify(document), {
|
|
3858
|
-
mode: 384,
|
|
3859
|
-
dirMode: 448
|
|
3860
3942
|
});
|
|
3861
3943
|
}
|
|
3862
3944
|
};
|
|
@@ -3898,6 +3980,14 @@ const SUBSCRIPTION_KEY_SOURCES = [
|
|
|
3898
3980
|
provider: "minimax-token-plan",
|
|
3899
3981
|
key: "minmaxApiKey"
|
|
3900
3982
|
},
|
|
3983
|
+
{
|
|
3984
|
+
provider: "minimax-token-plan-cn",
|
|
3985
|
+
key: "minmaxApiKey"
|
|
3986
|
+
},
|
|
3987
|
+
{
|
|
3988
|
+
provider: "minimax-cn",
|
|
3989
|
+
key: "minmaxApiKey"
|
|
3990
|
+
},
|
|
3901
3991
|
{
|
|
3902
3992
|
provider: "openrouter",
|
|
3903
3993
|
key: "openrouterApiKey"
|
|
@@ -4283,7 +4373,11 @@ function apply(ctx, config = {}) {
|
|
|
4283
4373
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
4284
4374
|
const piProviders = await readPiAiProviders(ctx.settings);
|
|
4285
4375
|
const providers = { ...piProviders };
|
|
4286
|
-
|
|
4376
|
+
const deepseekKey = providers["deepseek"]?.apiKeyEnv;
|
|
4377
|
+
if (deepseekKey === void 0 || deepseekKey === "") providers["deepseek"] = {
|
|
4378
|
+
...providers["deepseek"],
|
|
4379
|
+
apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV
|
|
4380
|
+
};
|
|
4287
4381
|
const balances = await queryBalances(ctx, providers);
|
|
4288
4382
|
const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
|
|
4289
4383
|
const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
|
|
@@ -4335,9 +4429,25 @@ function apply(ctx, config = {}) {
|
|
|
4335
4429
|
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
4336
4430
|
return;
|
|
4337
4431
|
}
|
|
4432
|
+
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
4433
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
4434
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
4435
|
+
return;
|
|
4436
|
+
}
|
|
4437
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
4438
|
+
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
4439
|
+
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
4338
4442
|
try {
|
|
4339
4443
|
let body = "";
|
|
4340
|
-
for await (const chunk of req)
|
|
4444
|
+
for await (const chunk of req) {
|
|
4445
|
+
body += String(chunk);
|
|
4446
|
+
if (body.length > 512) {
|
|
4447
|
+
res.end(JSON.stringify({ error: "body too large" }));
|
|
4448
|
+
return;
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4341
4451
|
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
4342
4452
|
if (usageSettingsScope === void 0) {
|
|
4343
4453
|
res.end(JSON.stringify({ error: "settings unavailable" }));
|
|
@@ -4360,10 +4470,15 @@ function apply(ctx, config = {}) {
|
|
|
4360
4470
|
};
|
|
4361
4471
|
const refreshQuotas = async () => {
|
|
4362
4472
|
const { keys, identified } = await resolveSubscriptionKeys(ctx.settings, ctx.credentials);
|
|
4363
|
-
const
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4473
|
+
const piProviders = await readPiAiProviders(ctx.settings);
|
|
4474
|
+
const rows = [...await collectSubscriptions(keys, identified.filter((item) => item.adapter).map((item) => {
|
|
4475
|
+
const baseURL = piProviders[item.provider]?.baseURL;
|
|
4476
|
+
return {
|
|
4477
|
+
provider: item.provider,
|
|
4478
|
+
...item.region === void 0 ? {} : { region: item.region },
|
|
4479
|
+
...typeof baseURL === "string" && baseURL !== "" ? { baseUrl: baseURL } : {}
|
|
4480
|
+
};
|
|
4481
|
+
}))].map((row) => {
|
|
4367
4482
|
const planType = planTypeOf(row.provider);
|
|
4368
4483
|
const subscriptionAmount = subscriptionFeeCnyOf(row.provider, live.rate);
|
|
4369
4484
|
return {
|
|
@@ -4391,7 +4506,11 @@ function apply(ctx, config = {}) {
|
|
|
4391
4506
|
handler: async (req, res) => {
|
|
4392
4507
|
if (!guardLoopback(req, res)) return;
|
|
4393
4508
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
4394
|
-
if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS)
|
|
4509
|
+
if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
|
|
4510
|
+
await refreshQuotas();
|
|
4511
|
+
} catch (error) {
|
|
4512
|
+
console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
|
|
4513
|
+
}
|
|
4395
4514
|
res.end(JSON.stringify({ quotas: quotaCache.quotas }));
|
|
4396
4515
|
}
|
|
4397
4516
|
}), "usage-billing: subscriptions route");
|
|
@@ -4423,7 +4542,11 @@ function apply(ctx, config = {}) {
|
|
|
4423
4542
|
handler: async (req, res) => {
|
|
4424
4543
|
if (!guardLoopback(req, res)) return;
|
|
4425
4544
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
4426
|
-
if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS)
|
|
4545
|
+
if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) try {
|
|
4546
|
+
await refreshRelay();
|
|
4547
|
+
} catch (error) {
|
|
4548
|
+
console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
|
|
4549
|
+
}
|
|
4427
4550
|
const diagnostics = relayCache.quotas.map((row) => ({
|
|
4428
4551
|
route: row.route,
|
|
4429
4552
|
origin: row.origin,
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* 3. Token 结构 KPI(缓存命中率 / reasoning 占比 / 输入:输出比 / 峰值日)。
|
|
7
7
|
*/
|
|
8
8
|
import type { UsageBillingKey } from './locales.ts';
|
|
9
|
-
import { type CostCurrency } from './pricing.ts';
|
|
10
9
|
import type { UsageStats } from './UsageBilling.tsx';
|
|
11
10
|
/**
|
|
12
11
|
* Token 洞察面板。
|
|
@@ -16,7 +15,6 @@ import type { UsageStats } from './UsageBilling.tsx';
|
|
|
16
15
|
*/
|
|
17
16
|
export declare function TokenPanel(props: {
|
|
18
17
|
stats: UsageStats;
|
|
19
|
-
currency: CostCurrency;
|
|
20
18
|
trendDays: 7 | 30;
|
|
21
19
|
onTrendDays: (d: 7 | 30) => void;
|
|
22
20
|
t: (key: UsageBillingKey) => string;
|
|
@@ -223,6 +223,7 @@ export interface UsageStats {
|
|
|
223
223
|
}
|
|
224
224
|
/**
|
|
225
225
|
* 拉取官方余额差对账提示(drift 时非空),供余额面板展示;失败返回 undefined。
|
|
226
|
+
* 复用 {@link fetchBalanceDoc} 的同一响应,导出供对账提示渲染测试单独解析。
|
|
226
227
|
* @returns the reconcile notice, or undefined on any failure / no drift.
|
|
227
228
|
*/
|
|
228
229
|
export declare function fetchReconcile(): Promise<ReconcileNotice | undefined>;
|
|
@@ -225,10 +225,11 @@ export declare function computeCost(entry: ModelEntry, buckets: TokenUsageBucket
|
|
|
225
225
|
* @param buckets - token usage counts.
|
|
226
226
|
* @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
|
|
227
227
|
* @param peakShare - fallback mix used only when `timeMs` is missing.
|
|
228
|
-
* @returns the estimated cost in
|
|
228
|
+
* @returns the estimated cost in CNY(USD 计价模型已按当前汇率折算)。
|
|
229
229
|
*/
|
|
230
230
|
export declare function computeCostAt(entry: ModelEntry, buckets: TokenUsageBuckets, timeMs: number | null | undefined, peakShare?: number): number;
|
|
231
|
-
/** 人民币 →
|
|
231
|
+
/** 人民币 → 美元(显示换算用):用当前生效汇率(实时优先,缺失回退内置),
|
|
232
|
+
* 与计价链路的 `currentRate()` 同口径,避免实时汇率生效时 USD 显示与计价不一致。 */
|
|
232
233
|
export declare function cnyToUsd(cny: number): number;
|
|
233
234
|
/**
|
|
234
235
|
* Format an amount with adaptive precision and the given currency symbol.
|
|
@@ -33,7 +33,8 @@ export interface FloatWindowPrefs {
|
|
|
33
33
|
export declare const DEFAULT_FLOAT_WINDOW_PREFS: FloatWindowPrefs;
|
|
34
34
|
/** localStorage key(与 budget store 的 `dsh.ui-usage-billing.*` 命名空间一致)。 */
|
|
35
35
|
export declare const FLOAT_WINDOW_STORAGE_KEY = "dsh.ui-usage-billing.float";
|
|
36
|
-
/** 读取浮窗偏好(含损坏/缺失回退到默认)。仅在浏览器半区调用。
|
|
36
|
+
/** 读取浮窗偏好(含损坏/缺失回退到默认)。仅在浏览器半区调用。
|
|
37
|
+
* 返回全新对象(含 targets 数组拷贝),避免调用方就地修改污染共享默认值。 */
|
|
37
38
|
export declare function loadFloatWindowPrefs(): FloatWindowPrefs;
|
|
38
39
|
/** 写入浮窗偏好。失败静默(展示偏好非关键)。 */
|
|
39
40
|
export declare function saveFloatWindowPrefs(prefs: FloatWindowPrefs): void;
|
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": "0.9.
|
|
4
|
+
"version": "0.9.20",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -47,42 +47,42 @@
|
|
|
47
47
|
},
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@deepseek-ai/dsh-api-remotes": "
|
|
51
|
-
"@deepseek-ai/dsh-atomic-write": "
|
|
52
|
-
"@deepseek-ai/dsh-client-connection": "
|
|
53
|
-
"@deepseek-ai/dsh-host-webserver": "
|
|
54
|
-
"@deepseek-ai/dsh-client-locale": "
|
|
55
|
-
"@deepseek-ai/dsh-client-runtime": "
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-conversation": "
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-primitives": "
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-slots": "
|
|
60
|
-
"@deepseek-ai/dsh-credentials": "
|
|
61
|
-
"@deepseek-ai/dsh-invariants": "
|
|
62
|
-
"@deepseek-ai/dsh-llm": "
|
|
63
|
-
"@deepseek-ai/dsh-session": "
|
|
64
|
-
"@deepseek-ai/dsh-session-persistence": "
|
|
65
|
-
"@deepseek-ai/dsh-settings": "
|
|
66
|
-
"@deepseek-ai/dsh-tools": "
|
|
67
|
-
"@deepseek-ai/schemastery": "
|
|
68
|
-
"@deepseek-ai/cordis": "
|
|
50
|
+
"@deepseek-ai/dsh-api-remotes": "*",
|
|
51
|
+
"@deepseek-ai/dsh-atomic-write": "*",
|
|
52
|
+
"@deepseek-ai/dsh-client-connection": "*",
|
|
53
|
+
"@deepseek-ai/dsh-host-webserver": "*",
|
|
54
|
+
"@deepseek-ai/dsh-client-locale": "*",
|
|
55
|
+
"@deepseek-ai/dsh-client-runtime": "*",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-conversation": "*",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "*",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-primitives": "*",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "*",
|
|
60
|
+
"@deepseek-ai/dsh-credentials": "*",
|
|
61
|
+
"@deepseek-ai/dsh-invariants": "*",
|
|
62
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
63
|
+
"@deepseek-ai/dsh-session": "*",
|
|
64
|
+
"@deepseek-ai/dsh-session-persistence": "*",
|
|
65
|
+
"@deepseek-ai/dsh-settings": "*",
|
|
66
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
67
|
+
"@deepseek-ai/schemastery": "*",
|
|
68
|
+
"@deepseek-ai/cordis": "*",
|
|
69
69
|
"react": "^18.2.0"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"clsx": "^2.1.1"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@deepseek-ai/dsh-client-locale": "
|
|
76
|
-
"@deepseek-ai/dsh-client-runtime": "
|
|
77
|
-
"@deepseek-ai/dsh-client-test-runtime": "
|
|
78
|
-
"@deepseek-ai/dsh-client-ui-conversation": "
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-primitives": "
|
|
80
|
-
"@deepseek-ai/dsh-client-ui-slots": "
|
|
81
|
-
"@deepseek-ai/dsh-invariants": "
|
|
75
|
+
"@deepseek-ai/dsh-client-locale": "*",
|
|
76
|
+
"@deepseek-ai/dsh-client-runtime": "*",
|
|
77
|
+
"@deepseek-ai/dsh-client-test-runtime": "*",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-conversation": "*",
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-primitives": "*",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-slots": "*",
|
|
81
|
+
"@deepseek-ai/dsh-invariants": "*",
|
|
82
82
|
"@testing-library/react": "^16.1.0",
|
|
83
83
|
"@types/react": "~18.3.1",
|
|
84
84
|
"@types/node": "^22.20.0",
|
|
85
|
-
"@deepseek-ai/cordis": "
|
|
85
|
+
"@deepseek-ai/cordis": "*",
|
|
86
86
|
"react": "^18.2.0",
|
|
87
87
|
"react-dom": "^18.2.0",
|
|
88
88
|
"lightningcss": "^1.32.0",
|