@kenz1117/dsh-ui-usage-billing 0.9.8 → 0.9.10
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 +12 -6
- package/README.md +10 -6
- package/lib/client.js +416 -327
- package/lib/index.js +133 -25
- package/lib/types/aggregate.d.ts +4 -0
- package/lib/types/client/UsageBilling.d.ts +4 -0
- package/lib/types/client/export.d.ts +2 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/pricing-shared.d.ts +11 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -68,7 +68,8 @@ const PLAN_KNOWLEDGE = {
|
|
|
68
68
|
"ark-token-plan": { type: "code" },
|
|
69
69
|
"doubao-token-plan": { type: "code" },
|
|
70
70
|
"minimax": { type: "code" },
|
|
71
|
-
"minimax-token-plan": { type: "code" }
|
|
71
|
+
"minimax-token-plan": { type: "code" },
|
|
72
|
+
"minimax-token-plan-cn": { type: "code" }
|
|
72
73
|
};
|
|
73
74
|
/**
|
|
74
75
|
* 订阅/plan provider id 变体 → PLAN_KNOWLEDGE 规范键(引用 dsh-spend 的别名归一化)。
|
|
@@ -1128,6 +1129,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds, routes
|
|
|
1128
1129
|
byDay: /* @__PURE__ */ new Map(),
|
|
1129
1130
|
byDayModels: /* @__PURE__ */ new Map(),
|
|
1130
1131
|
bySite: /* @__PURE__ */ new Map(),
|
|
1132
|
+
unpricedModels: /* @__PURE__ */ new Set(),
|
|
1131
1133
|
planCalls: /* @__PURE__ */ new Map(),
|
|
1132
1134
|
turns: [],
|
|
1133
1135
|
perf: [],
|
|
@@ -1213,6 +1215,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds, routes
|
|
|
1213
1215
|
if (usage === void 0) continue;
|
|
1214
1216
|
const modelKey = key;
|
|
1215
1217
|
const day = dayStamp(event.time);
|
|
1218
|
+
if (!subscription && !isPriced(modelKey)) fold.unpricedModels.add(modelKey);
|
|
1216
1219
|
foldUsage(fold.total, usage, modelKey, subscription, event.time, official);
|
|
1217
1220
|
foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
|
|
1218
1221
|
foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
|
|
@@ -1401,6 +1404,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1401
1404
|
const byDay = /* @__PURE__ */ new Map();
|
|
1402
1405
|
const byDayModels = /* @__PURE__ */ new Map();
|
|
1403
1406
|
const bySite = /* @__PURE__ */ new Map();
|
|
1407
|
+
const unpricedModels = /* @__PURE__ */ new Set();
|
|
1404
1408
|
const planCalls = /* @__PURE__ */ new Map();
|
|
1405
1409
|
const sessionRows = [];
|
|
1406
1410
|
const turnRows = [];
|
|
@@ -1424,6 +1428,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1424
1428
|
for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
|
|
1425
1429
|
for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
|
|
1426
1430
|
for (const [siteKey, cell] of fold.bySite) mergeUsageInto(usageCell(bySite, siteKey), cell);
|
|
1431
|
+
for (const id of fold.unpricedModels) unpricedModels.add(id);
|
|
1427
1432
|
for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
|
|
1428
1433
|
for (const sample of fold.perf) {
|
|
1429
1434
|
let modelAccum = perfModel.get(sample.model);
|
|
@@ -1520,6 +1525,7 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
1520
1525
|
byTurn: turnRows.slice(0, 200),
|
|
1521
1526
|
byWorkspace: workspaces.slice(0, 100),
|
|
1522
1527
|
...bySite.size === 0 ? {} : { bySite: toRecord(bySite) },
|
|
1528
|
+
...unpricedModels.size === 0 ? {} : { unpricedModels: [...unpricedModels].sort() },
|
|
1523
1529
|
...perf === void 0 ? {} : { perf },
|
|
1524
1530
|
byRole: (() => {
|
|
1525
1531
|
const chars = roles.userChars + roles.toolChars;
|
|
@@ -2370,10 +2376,11 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
|
|
|
2370
2376
|
"wenxin": "百度文心 Plan",
|
|
2371
2377
|
"minimax": "MiniMax Coding Plan",
|
|
2372
2378
|
"minimax-token-plan": "MiniMax Token Plan",
|
|
2379
|
+
"minimax-token-plan-cn": "MiniMax Token Plan(国内)",
|
|
2373
2380
|
"openrouter": "OpenRouter"
|
|
2374
2381
|
};
|
|
2375
2382
|
/** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
|
|
2376
|
-
const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-token-plan|openrouter)", "i");
|
|
2383
|
+
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");
|
|
2377
2384
|
/** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
|
|
2378
2385
|
function isSubscriptionProviderId(providerId) {
|
|
2379
2386
|
if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
|
|
@@ -2387,6 +2394,7 @@ const SUBSCRIPTION_ADAPTERS = {
|
|
|
2387
2394
|
"opencode-go": { collect: collectOpenCodeGo },
|
|
2388
2395
|
"minimax": { collect: collectMiniMax },
|
|
2389
2396
|
"minimax-token-plan": { collect: collectMiniMax },
|
|
2397
|
+
"minimax-token-plan-cn": { collect: collectMiniMax },
|
|
2390
2398
|
"openrouter": { collect: collectOpenRouter }
|
|
2391
2399
|
};
|
|
2392
2400
|
/** 有额度适配器的 provider id 集合(识别用)。 */
|
|
@@ -2759,13 +2767,29 @@ function parseMiniMaxRemains(body) {
|
|
|
2759
2767
|
}) ?? rows[0] ?? void 0;
|
|
2760
2768
|
return [minmaxWindow(record, "session", "current_interval_remaining_percent", "current_interval_status", "end_time"), minmaxWindow(record, "weekly", "current_weekly_remaining_percent", "current_weekly_status", "weekly_end_time")].filter((hit) => hit !== null);
|
|
2761
2769
|
}
|
|
2762
|
-
/**
|
|
2770
|
+
/**
|
|
2771
|
+
* Resolve the MiniMax API host based on the configured provider id.
|
|
2772
|
+
*
|
|
2773
|
+
* 国内开发者走 MiniMax(`api.minimaxi.com`),海外走 MiniMax(`minimaxi.com`)。
|
|
2774
|
+
* User-explicit `config.baseUrl` wins when set, so deployments in either
|
|
2775
|
+
* region can still override the auto-pick (e.g. proxies / staging).
|
|
2776
|
+
*/
|
|
2777
|
+
function resolveMiniMaxBaseUrl(config) {
|
|
2778
|
+
if (typeof config.baseUrl === "string" && config.baseUrl.trim() !== "") return config.baseUrl;
|
|
2779
|
+
return config.provider === "minimax-token-plan-cn" ? "https://api.minimaxi.com" : "https://www.minimaxi.com";
|
|
2780
|
+
}
|
|
2781
|
+
/** Display name for a MiniMax quota row, aligned with the display-name map. */
|
|
2782
|
+
function minmaxDisplayName(provider) {
|
|
2783
|
+
return SUBSCRIPTION_DISPLAY_NAMES[provider] ?? "MiniMax Coding Plan";
|
|
2784
|
+
}
|
|
2785
|
+
/** Collect the MiniMax Token Plan quota (CN + INTL). */
|
|
2763
2786
|
async function collectMiniMax(keys, config, timeoutMs) {
|
|
2764
2787
|
const apiKey = keys.minmaxApiKey.trim();
|
|
2765
|
-
const base = config
|
|
2788
|
+
const base = resolveMiniMaxBaseUrl(config);
|
|
2789
|
+
const displayName = minmaxDisplayName(config.provider);
|
|
2766
2790
|
if (apiKey === "") return {
|
|
2767
2791
|
provider: config.provider,
|
|
2768
|
-
displayName
|
|
2792
|
+
displayName,
|
|
2769
2793
|
status: "not-configured",
|
|
2770
2794
|
windows: []
|
|
2771
2795
|
};
|
|
@@ -2776,14 +2800,14 @@ async function collectMiniMax(keys, config, timeoutMs) {
|
|
|
2776
2800
|
} }, timeoutMs));
|
|
2777
2801
|
return {
|
|
2778
2802
|
provider: config.provider,
|
|
2779
|
-
displayName
|
|
2803
|
+
displayName,
|
|
2780
2804
|
status: windows.length > 0 ? "ok" : "invalid-response",
|
|
2781
2805
|
windows
|
|
2782
2806
|
};
|
|
2783
2807
|
} catch (error) {
|
|
2784
2808
|
return {
|
|
2785
2809
|
provider: config.provider,
|
|
2786
|
-
displayName
|
|
2810
|
+
displayName,
|
|
2787
2811
|
status: statusOf$1(error),
|
|
2788
2812
|
windows: []
|
|
2789
2813
|
};
|
|
@@ -2892,6 +2916,10 @@ const relayGate = createCooldownGate({
|
|
|
2892
2916
|
});
|
|
2893
2917
|
/** Abort a relay fetch when the upstream hangs beyond this budget. */
|
|
2894
2918
|
const FETCH_TIMEOUT_MS = 8e3;
|
|
2919
|
+
/** 指纹识别缓存 TTL(毫秒):识别结果低频变化,5 分钟内同 origin 不再重复探测。 */
|
|
2920
|
+
const FINGERPRINT_TTL_MS = 300 * 1e3;
|
|
2921
|
+
/** 每 origin 的识别结果缓存:`kind` 是识别出的中转站程序,`at` 是探测时刻。 */
|
|
2922
|
+
const fingerprintCache = /* @__PURE__ */ new Map();
|
|
2895
2923
|
/** Number, or null when the value is not a finite number (nor numeric string). */
|
|
2896
2924
|
function numberOrNull(value) {
|
|
2897
2925
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
@@ -3035,7 +3063,8 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3035
3063
|
origin: originOf(route.baseURL),
|
|
3036
3064
|
displayName: route.displayName ?? route.route
|
|
3037
3065
|
};
|
|
3038
|
-
|
|
3066
|
+
const gateKey = `${route.baseURL}::${route.apiKeyEnv}`;
|
|
3067
|
+
if (!relayGate.check(gateKey)) return {
|
|
3039
3068
|
...base,
|
|
3040
3069
|
kind: "unknown",
|
|
3041
3070
|
status: "unavailable"
|
|
@@ -3046,12 +3075,22 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3046
3075
|
kind: "unknown",
|
|
3047
3076
|
status: "not-configured"
|
|
3048
3077
|
};
|
|
3078
|
+
const cached = fingerprintCache.get(base.origin);
|
|
3079
|
+
if (cached !== void 0 && Date.now() - cached.at < FINGERPRINT_TTL_MS) return {
|
|
3080
|
+
...base,
|
|
3081
|
+
kind: cached.kind,
|
|
3082
|
+
status: "ok"
|
|
3083
|
+
};
|
|
3049
3084
|
try {
|
|
3050
3085
|
const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
|
|
3051
3086
|
if (sub2.ok) {
|
|
3052
3087
|
const parsed = parseSub2ApiUsage(sub2.data);
|
|
3053
3088
|
if (parsed !== null) {
|
|
3054
|
-
relayGate.success(
|
|
3089
|
+
relayGate.success(gateKey);
|
|
3090
|
+
fingerprintCache.set(base.origin, {
|
|
3091
|
+
kind: "sub2api",
|
|
3092
|
+
at: Date.now()
|
|
3093
|
+
});
|
|
3055
3094
|
return {
|
|
3056
3095
|
...base,
|
|
3057
3096
|
kind: "sub2api",
|
|
@@ -3060,7 +3099,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3060
3099
|
...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
|
|
3061
3100
|
};
|
|
3062
3101
|
}
|
|
3063
|
-
relayGate.fail(
|
|
3102
|
+
relayGate.fail(gateKey);
|
|
3064
3103
|
return {
|
|
3065
3104
|
...base,
|
|
3066
3105
|
kind: "sub2api",
|
|
@@ -3068,7 +3107,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3068
3107
|
};
|
|
3069
3108
|
}
|
|
3070
3109
|
if (sub2.status === 401 || sub2.status === 403) {
|
|
3071
|
-
relayGate.fail(
|
|
3110
|
+
relayGate.fail(gateKey);
|
|
3072
3111
|
return {
|
|
3073
3112
|
...base,
|
|
3074
3113
|
kind: "unknown",
|
|
@@ -3077,7 +3116,7 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3077
3116
|
}
|
|
3078
3117
|
const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
|
|
3079
3118
|
if (na.status === 401 || na.status === 403) {
|
|
3080
|
-
relayGate.fail(
|
|
3119
|
+
relayGate.fail(gateKey);
|
|
3081
3120
|
return {
|
|
3082
3121
|
...base,
|
|
3083
3122
|
kind: "unknown",
|
|
@@ -3087,7 +3126,11 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3087
3126
|
if (na.ok) {
|
|
3088
3127
|
const parsed = parseNewApiStatus(na.data);
|
|
3089
3128
|
if (parsed !== null) {
|
|
3090
|
-
relayGate.success(
|
|
3129
|
+
relayGate.success(gateKey);
|
|
3130
|
+
fingerprintCache.set(base.origin, {
|
|
3131
|
+
kind: "new-api",
|
|
3132
|
+
at: Date.now()
|
|
3133
|
+
});
|
|
3091
3134
|
return {
|
|
3092
3135
|
...base,
|
|
3093
3136
|
kind: "new-api",
|
|
@@ -3095,21 +3138,21 @@ async function queryRelayQuota(ctx, route) {
|
|
|
3095
3138
|
...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
|
|
3096
3139
|
};
|
|
3097
3140
|
}
|
|
3098
|
-
relayGate.fail(
|
|
3141
|
+
relayGate.fail(gateKey);
|
|
3099
3142
|
return {
|
|
3100
3143
|
...base,
|
|
3101
3144
|
kind: "new-api",
|
|
3102
3145
|
status: "invalid-response"
|
|
3103
3146
|
};
|
|
3104
3147
|
}
|
|
3105
|
-
relayGate.fail(
|
|
3148
|
+
relayGate.fail(gateKey);
|
|
3106
3149
|
return {
|
|
3107
3150
|
...base,
|
|
3108
3151
|
kind: "unknown",
|
|
3109
3152
|
status: "unavailable"
|
|
3110
3153
|
};
|
|
3111
3154
|
} catch (error) {
|
|
3112
|
-
relayGate.fail(
|
|
3155
|
+
relayGate.fail(gateKey);
|
|
3113
3156
|
return {
|
|
3114
3157
|
...base,
|
|
3115
3158
|
kind: "unknown",
|
|
@@ -3153,6 +3196,35 @@ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
|
3153
3196
|
* missing file answers `{ error }` so the dashboard shows zeros, never
|
|
3154
3197
|
* fabricated samples.
|
|
3155
3198
|
*/
|
|
3199
|
+
/** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
|
|
3200
|
+
* 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
|
|
3201
|
+
function isLoopbackPeer(req) {
|
|
3202
|
+
const address = req.socket.remoteAddress;
|
|
3203
|
+
if (address === void 0) return false;
|
|
3204
|
+
return address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
|
|
3205
|
+
}
|
|
3206
|
+
/** 校验 Host 头是本机回环(127.x / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。 */
|
|
3207
|
+
function isLoopbackHost(req) {
|
|
3208
|
+
const host = req.headers.host;
|
|
3209
|
+
if (host === void 0 || host === "") return true;
|
|
3210
|
+
const name = host.split(":")[0];
|
|
3211
|
+
return name === "localhost" || name === "::1" || name !== void 0 && name.startsWith("127.");
|
|
3212
|
+
}
|
|
3213
|
+
/**
|
|
3214
|
+
* 回环防护守卫:仅接受回环 GET 请求(peer socket 地址 + Host 头同时校验)。
|
|
3215
|
+
* 不满足时返回 403 并结束响应;调用方在 handler 顶部调用,返回 false 即已拒绝。
|
|
3216
|
+
* @param req - 当前请求。
|
|
3217
|
+
* @param res - 当前响应。
|
|
3218
|
+
* @returns 是否放行;false = 已拒绝并结束响应。
|
|
3219
|
+
*/
|
|
3220
|
+
function guardLoopback(req, res) {
|
|
3221
|
+
if (!(req.method === "GET" || req.method === "POST") || !isLoopbackPeer(req) || !isLoopbackHost(req)) {
|
|
3222
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
3223
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
3224
|
+
return false;
|
|
3225
|
+
}
|
|
3226
|
+
return true;
|
|
3227
|
+
}
|
|
3156
3228
|
/** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
|
|
3157
3229
|
const usageBillingSettingsNs = settingsNamespace(BILLING_SETTINGS_NAMESPACE);
|
|
3158
3230
|
/** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
|
|
@@ -3409,10 +3481,12 @@ function apply(ctx, config = {}) {
|
|
|
3409
3481
|
"today",
|
|
3410
3482
|
"month",
|
|
3411
3483
|
"session",
|
|
3412
|
-
"all"
|
|
3484
|
+
"all",
|
|
3485
|
+
"bySite",
|
|
3486
|
+
"relay"
|
|
3413
3487
|
],
|
|
3414
3488
|
required: true,
|
|
3415
|
-
description: "统计范围:today / month / session / all"
|
|
3489
|
+
description: "统计范围:today / month / session / all / bySite / relay"
|
|
3416
3490
|
} },
|
|
3417
3491
|
output: {
|
|
3418
3492
|
schema: {
|
|
@@ -3465,6 +3539,26 @@ function apply(ctx, config = {}) {
|
|
|
3465
3539
|
input: stats.total.input,
|
|
3466
3540
|
output: stats.total.output
|
|
3467
3541
|
};
|
|
3542
|
+
if (args.range === "bySite" || args.range === "relay") {
|
|
3543
|
+
const bySite = stats.bySite ?? {};
|
|
3544
|
+
let cost = 0;
|
|
3545
|
+
let calls = 0;
|
|
3546
|
+
let input = 0;
|
|
3547
|
+
let output = 0;
|
|
3548
|
+
for (const usage of Object.values(bySite)) {
|
|
3549
|
+
cost += usage.cost;
|
|
3550
|
+
calls += usage.calls;
|
|
3551
|
+
input += usage.input;
|
|
3552
|
+
output += usage.output;
|
|
3553
|
+
}
|
|
3554
|
+
return {
|
|
3555
|
+
range: args.range,
|
|
3556
|
+
cost,
|
|
3557
|
+
calls,
|
|
3558
|
+
input,
|
|
3559
|
+
output
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3468
3562
|
if (args.range === "today") {
|
|
3469
3563
|
const day = stats.byDay[dayStamp(Date.now())];
|
|
3470
3564
|
return day === void 0 ? zero : {
|
|
@@ -3537,7 +3631,8 @@ function apply(ctx, config = {}) {
|
|
|
3537
3631
|
ctx.effect(() => ctx.webServer.register({
|
|
3538
3632
|
kind: "exact",
|
|
3539
3633
|
path: "/api/billing/pricing",
|
|
3540
|
-
handler: async (
|
|
3634
|
+
handler: async (req, res) => {
|
|
3635
|
+
if (!guardLoopback(req, res)) return;
|
|
3541
3636
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3542
3637
|
res.end(JSON.stringify(live));
|
|
3543
3638
|
}
|
|
@@ -3545,7 +3640,8 @@ function apply(ctx, config = {}) {
|
|
|
3545
3640
|
ctx.effect(() => ctx.webServer.register({
|
|
3546
3641
|
kind: "exact",
|
|
3547
3642
|
path: "/api/billing/balance",
|
|
3548
|
-
handler: async (
|
|
3643
|
+
handler: async (req, res) => {
|
|
3644
|
+
if (!guardLoopback(req, res)) return;
|
|
3549
3645
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3550
3646
|
const providers = { ...await readPiAiProviders(ctx.settings) };
|
|
3551
3647
|
if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
|
|
@@ -3559,6 +3655,7 @@ function apply(ctx, config = {}) {
|
|
|
3559
3655
|
kind: "exact",
|
|
3560
3656
|
path: "/api/billing/usage-tool",
|
|
3561
3657
|
handler: async (req, res) => {
|
|
3658
|
+
if (!guardLoopback(req, res)) return;
|
|
3562
3659
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3563
3660
|
const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
|
|
3564
3661
|
if (req.method === "GET") {
|
|
@@ -3623,7 +3720,8 @@ function apply(ctx, config = {}) {
|
|
|
3623
3720
|
ctx.effect(() => ctx.webServer.register({
|
|
3624
3721
|
kind: "exact",
|
|
3625
3722
|
path: "/api/billing/subscriptions",
|
|
3626
|
-
handler: async (
|
|
3723
|
+
handler: async (req, res) => {
|
|
3724
|
+
if (!guardLoopback(req, res)) return;
|
|
3627
3725
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3628
3726
|
if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) await refreshQuotas();
|
|
3629
3727
|
res.end(JSON.stringify({ quotas: quotaCache.quotas }));
|
|
@@ -3653,16 +3751,26 @@ function apply(ctx, config = {}) {
|
|
|
3653
3751
|
ctx.effect(() => ctx.webServer.register({
|
|
3654
3752
|
kind: "exact",
|
|
3655
3753
|
path: "/api/billing/relay-quotas",
|
|
3656
|
-
handler: async (
|
|
3754
|
+
handler: async (req, res) => {
|
|
3755
|
+
if (!guardLoopback(req, res)) return;
|
|
3657
3756
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3658
3757
|
if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) await refreshRelay();
|
|
3659
|
-
|
|
3758
|
+
const diagnostics = relayCache.quotas.map((row) => ({
|
|
3759
|
+
route: row.route,
|
|
3760
|
+
origin: row.origin,
|
|
3761
|
+
kind: row.kind
|
|
3762
|
+
}));
|
|
3763
|
+
res.end(JSON.stringify({
|
|
3764
|
+
quotas: relayCache.quotas,
|
|
3765
|
+
diagnostics
|
|
3766
|
+
}));
|
|
3660
3767
|
}
|
|
3661
3768
|
}), "usage-billing: relay-quotas route");
|
|
3662
3769
|
ctx.effect(() => ctx.webServer.register({
|
|
3663
3770
|
kind: "exact",
|
|
3664
3771
|
path: "/api/billing/usage-stats",
|
|
3665
|
-
handler: async (
|
|
3772
|
+
handler: async (req, res) => {
|
|
3773
|
+
if (!guardLoopback(req, res)) return;
|
|
3666
3774
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
3667
3775
|
try {
|
|
3668
3776
|
const stats = await aggregator.aggregate();
|
|
@@ -3695,4 +3803,4 @@ function apply(ctx, config = {}) {
|
|
|
3695
3803
|
}), "usage-billing: usage-stats route");
|
|
3696
3804
|
}
|
|
3697
3805
|
//#endregion
|
|
3698
|
-
export { apply, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };
|
|
3806
|
+
export { apply, guardLoopback, inject, readPiAiProviderRoutes, resolveSubscriptionKeys };
|
package/lib/types/aggregate.d.ts
CHANGED
|
@@ -147,6 +147,8 @@ export interface UsageStatsDocument {
|
|
|
147
147
|
* 属估算口径,UI 需标注)。旧快照可能缺失。
|
|
148
148
|
*/
|
|
149
149
|
byRole?: RoleCost;
|
|
150
|
+
/** 不可计价的模型 id(未收录 / 无价,费用按 0 计);供面板提示用户自查与反馈。 */
|
|
151
|
+
unpricedModels?: readonly string[];
|
|
150
152
|
/**
|
|
151
153
|
* 性能指标(TTFT / 生成速度 / 总延迟)按模型与按小时聚合;旧快照可能缺失。
|
|
152
154
|
* 口径:TTFT = request/header → 首个内容 chunk;生成速度 = 输出 token ÷ 生成时长;
|
|
@@ -269,6 +271,8 @@ interface SessionFold {
|
|
|
269
271
|
byDayModels: Map<string, Map<string, ModelUsage>>;
|
|
270
272
|
/** 中转站归组:按 provider 路由归类到站点/直连/未知路由(key = {@link siteBucketKey})。 */
|
|
271
273
|
bySite: Map<string, ModelUsage>;
|
|
274
|
+
/** 不可计价模型 id(未收录/无价,且非订阅)集合;跨会话合并后输出给面板提示。 */
|
|
275
|
+
unpricedModels: Set<string>;
|
|
272
276
|
/** 每个模型 key 在本会话内走订阅通道的调用数(合并时跨会话累加判定 plan)。 */
|
|
273
277
|
planCalls: Map<string, number>;
|
|
274
278
|
/** 每轮费用明细(按轮次号升序,不含 sessionId);sessionId 在合并时补齐。 */
|
|
@@ -59,6 +59,8 @@ export declare const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]
|
|
|
59
59
|
* 导出供守卫测试:短别名(mi/yi)仅允许前缀形式,防止 minimax 等误吞。
|
|
60
60
|
*/
|
|
61
61
|
export declare function providerFromModelKey(modelKey: string): string | undefined;
|
|
62
|
+
/** 仅供测试:暴露厂商映射表(subscriptionVendorOf 仍是唯一消费入口)。 */
|
|
63
|
+
export declare const SUBSCRIPTION_VENDORS_FOR_TEST: Readonly<Record<string, string>>;
|
|
62
64
|
/**
|
|
63
65
|
* 本月预计总花费:按本月已有记录的平均日消耗 × 本月天数外推;无本月记录时
|
|
64
66
|
* 回退为最近 7 天日均 × 本月天数;无任何记录时返回 0(调用方不展示)。
|
|
@@ -200,6 +202,8 @@ export interface UsageStats {
|
|
|
200
202
|
cost: number;
|
|
201
203
|
reasoning: number;
|
|
202
204
|
}>;
|
|
205
|
+
/** 不可计价模型 id(未收录/无价,费用按 0 计);旧快照可能缺失。 */
|
|
206
|
+
unpricedModels?: readonly string[];
|
|
203
207
|
/** 按角色费用归因(估算口径:输出实测,输入按消息长度摊分);旧快照可能缺失。 */
|
|
204
208
|
byRole?: {
|
|
205
209
|
user: number;
|
|
@@ -24,6 +24,8 @@ export interface SessionExportRow {
|
|
|
24
24
|
}
|
|
25
25
|
/** 按日 CSV:日期,调用,输入,输出,缓存命中,缓存未命中,费用(元)。 */
|
|
26
26
|
export declare function dayRowsCsv(byDay: Record<string, DayExportRow>): string;
|
|
27
|
+
/** 按站点(中转站/直连/未知路由)CSV:站点,类别,调用,费用(元)。 */
|
|
28
|
+
export declare function siteRowsCsv(bySite: Record<string, DayExportRow>): string;
|
|
27
29
|
/** 按会话 CSV:会话 id,标题,项目,调用,费用(元),最后活跃(ISO)。 */
|
|
28
30
|
export declare function sessionRowsCsv(rows: readonly SessionExportRow[]): string;
|
|
29
31
|
/** 导出文件名:带日期范围(usage-2026-08-01_2026-08-22.csv);无日期时只带前缀。 */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Locale dictionaries for the usage billing surface. */
|
|
2
|
-
export type UsageBillingKey = 'billing.title' | 'billing.subtitle' | 'billing.cost' | 'billing.todayCost' | 'billing.monthCost' | 'billing.yearCost' | 'billing.monthProjected' | 'billing.liveTurn' | 'billing.liveSession' | 'billing.totalCost' | 'billing.calls' | 'billing.cacheHitRate' | 'billing.tokens' | 'billing.inputTokens' | 'billing.outputTokens' | 'billing.avgCost' | 'billing.trend' | 'billing.trend7d' | 'billing.trend30d' | 'billing.trendEmpty' | 'billing.budget' | 'billing.sessions' | 'billing.project' | 'billing.lastActive' | 'billing.sessionOverflow' | 'billing.budgetTierBody' | 'billing.models' | 'billing.providerBilling' | 'billing.estimated' | 'billing.actual' | 'billing.pricing' | 'billing.showPricing' | 'billing.hidePricing' | 'billing.pricePerM' | 'billing.input' | 'billing.output' | 'billing.cacheHit' | 'billing.peak' | 'billing.offPeak' | 'billing.flat' | 'billing.peakHours' | 'billing.band' | 'billing.openDashboard' | 'billing.close' | 'billing.lastUpdated' | 'billing.noData' | 'billing.todayRate' | 'billing.rateLive' | 'billing.rateBuiltin' | 'billing.pricingTip' | 'billing.balance' | 'billing.balanceUnconfigured' | 'billing.balanceUnauthorized' | 'billing.balanceUnreachable' | 'billing.uncatalogued' | 'billing.estimatedPricing' | 'billing.balanceDays' | 'billing.balanceLowBody' | 'billing.subscriptions' | 'billing.subscriptionNotConfigured' | 'billing.subscriptionUnauthorized' | 'billing.subscriptionUnavailable' | 'billing.subscriptionInvalid' | 'billing.subscriptionRateLimited' | 'billing.subscriptionSession' | 'billing.subscriptionWeekly' | 'billing.subscriptionMonthly' | 'billing.subscriptionBilling' | 'billing.subscriptionRemaining' | 'billing.subscriptionExhausted' | 'billing.subscriptionReset' | 'billing.subscriptionNoApi' | 'billing.heatmapLess' | 'billing.heatmapMore' | 'billing.currency' | 'billing.currencyCny' | 'billing.currencyUsd' | 'billing.heatmap' | 'billing.rounds' | 'billing.anomaly' | 'billing.workspaces' | 'billing.plan' | 'billing.remaining' | 'billing.unknownModel' | 'billing.model' | 'billing.currentRound' | 'billing.costAbbr' | 'billing.tabOverview' | 'billing.tabTrends' | 'billing.tabProviders' | 'billing.tabDetails' | 'billing.tabPricing' | 'billing.tabSettings' | 'billing.settingsHead' | 'billing.settingsHint' | 'billing.budgetHint' | 'billing.peakAlertHint' | 'billing.peakAlertDescPeak' | 'billing.peakAlertDescOff' | 'billing.export' | 'billing.exportCsvDay' | 'billing.exportCsvSession' | 'billing.exportJson' | 'billing.peakShare' | 'billing.peakShareHint' | 'billing.weekCost' | 'billing.roleCost' | 'billing.roleUser' | 'billing.roleAssistant' | 'billing.roleTool' | 'billing.roleHint' | 'billing.tierPeak' | 'billing.tierOff' | 'billing.tierToPeak' | 'billing.tierToOff' | 'billing.tierAlertEnterPeak' | 'billing.tierAlertEnterOff' | 'billing.peakAlertTitlePeak' | 'billing.peakAlertTitleOff' | 'billing.peakAlert' | 'billing.peakAlertLeadMin' | 'billing.peakAlertPosCorner' | 'billing.peakAlertPosCenter' | 'billing.peakAlertModePeak' | 'billing.peakAlertModeOff' | 'billing.peakAlertModeBoth' | 'billing.peakAlertWebNotify' | 'billing.peakAlertPreview' | 'billing.planTypeCode' | 'billing.planTypeToken' | 'billing.subscriptionFeePerMonth' | 'billing.triggerToday' | 'billing.triggerMonth' | 'billing.subscriptionIncluded' | 'billing.free' | 'billing.official' | 'billing.thirdParty' | 'billing.officialCost' | 'billing.thirdPartyCost' | 'billing.perfSamples' | 'billing.perfTtft' | 'billing.perfP50' | 'billing.perfP90' | 'billing.perfTps' | 'billing.perfLatency' | 'billing.perfEstimated' | 'billing.perfEmpty' | 'billing.perfTpsUnit' | 'billing.perfTitle' | 'billing.perfHint' | 'billing.heatmapYear' | 'billing.heatmapMonth' | 'billing.activeDays' | 'billing.streakDays' | 'billing.subscriptionAutoDetect' | 'billing.pluginInfo' | 'billing.pluginName' | 'billing.pluginDescription' | 'billing.pluginVersion' | 'billing.pluginAuthor' | 'billing.pluginRepository' | 'billing.pluginNpm' | 'billing.pluginLicense' | 'billing.tabToken' | 'billing.tokenExport' | 'billing.tokenExportCsv' | 'billing.tokenCacheHitRate' | 'billing.tokenReasoningShare' | 'billing.tokenReasoningShort' | 'billing.tokenIo' | 'billing.tokenPeak' | 'billing.tokenDaily' | 'billing.tokenByModel' | 'billing.tokenMiss' | 'billing.tokenHit' | 'billing.tokenOutput' | 'billing.tokenTotal' | 'billing.tokenShare' | 'billing.usageStatsTool' | 'billing.usageStatsToolHint' | 'billing.balanceGranted' | 'billing.balanceTopped' | 'billing.balanceDaily' | 'billing.balanceDaysLong' | 'billing.balanceDaysUnit' | 'billing.popTodayModel' | 'billing.popNoConsumption' | 'billing.popQuotaAlert' | 'billing.popRiskNone' | 'billing.popTitle' | 'billing.popDirectLead' | 'billing.popSubLead' | 'billing.popBalanceNormal' | 'billing.popBalanceLow' | 'billing.popQuotaNormal' | 'billing.popQuotaLow' | 'billing.alertBalanceLow' | 'billing.alertQuotaLow' | 'billing.panelRelay' | 'billing.relaySite' | 'billing.relayDirect' | 'billing.relayUnknown' | 'billing.panelRelayQuota' | 'billing.relayBalance' | 'billing.relayNoQuota' | 'billing.relayWindowUsed' | 'billing.relayKindNewApi' | 'billing.relayKindSub2Api' | 'billing.relayKindUnknown' | 'billing.relayCalls';
|
|
2
|
+
export type UsageBillingKey = 'billing.title' | 'billing.subtitle' | 'billing.cost' | 'billing.todayCost' | 'billing.monthCost' | 'billing.yearCost' | 'billing.monthProjected' | 'billing.liveTurn' | 'billing.liveSession' | 'billing.totalCost' | 'billing.calls' | 'billing.cacheHitRate' | 'billing.tokens' | 'billing.inputTokens' | 'billing.outputTokens' | 'billing.avgCost' | 'billing.trend' | 'billing.trend7d' | 'billing.trend30d' | 'billing.trendEmpty' | 'billing.budget' | 'billing.sessions' | 'billing.project' | 'billing.lastActive' | 'billing.sessionOverflow' | 'billing.budgetTierBody' | 'billing.models' | 'billing.providerBilling' | 'billing.estimated' | 'billing.actual' | 'billing.pricing' | 'billing.showPricing' | 'billing.hidePricing' | 'billing.pricePerM' | 'billing.input' | 'billing.output' | 'billing.cacheHit' | 'billing.peak' | 'billing.offPeak' | 'billing.flat' | 'billing.peakHours' | 'billing.band' | 'billing.openDashboard' | 'billing.close' | 'billing.lastUpdated' | 'billing.noData' | 'billing.todayRate' | 'billing.rateLive' | 'billing.rateBuiltin' | 'billing.pricingTip' | 'billing.balance' | 'billing.balanceUnconfigured' | 'billing.balanceUnauthorized' | 'billing.balanceUnreachable' | 'billing.uncatalogued' | 'billing.estimatedPricing' | 'billing.balanceDays' | 'billing.balanceLowBody' | 'billing.subscriptions' | 'billing.subscriptionNotConfigured' | 'billing.subscriptionUnauthorized' | 'billing.subscriptionUnavailable' | 'billing.subscriptionInvalid' | 'billing.subscriptionRateLimited' | 'billing.subscriptionSession' | 'billing.subscriptionWeekly' | 'billing.subscriptionMonthly' | 'billing.subscriptionBilling' | 'billing.subscriptionRemaining' | 'billing.subscriptionExhausted' | 'billing.subscriptionReset' | 'billing.subscriptionNoApi' | 'billing.heatmapLess' | 'billing.heatmapMore' | 'billing.currency' | 'billing.currencyCny' | 'billing.currencyUsd' | 'billing.heatmap' | 'billing.rounds' | 'billing.anomaly' | 'billing.workspaces' | 'billing.plan' | 'billing.remaining' | 'billing.unknownModel' | 'billing.model' | 'billing.currentRound' | 'billing.costAbbr' | 'billing.tabOverview' | 'billing.tabTrends' | 'billing.tabProviders' | 'billing.tabDetails' | 'billing.tabPricing' | 'billing.tabSettings' | 'billing.settingsHead' | 'billing.settingsHint' | 'billing.budgetHint' | 'billing.peakAlertHint' | 'billing.peakAlertDescPeak' | 'billing.peakAlertDescOff' | 'billing.export' | 'billing.exportCsvDay' | 'billing.exportCsvSession' | 'billing.exportJson' | 'billing.peakShare' | 'billing.peakShareHint' | 'billing.weekCost' | 'billing.roleCost' | 'billing.roleUser' | 'billing.roleAssistant' | 'billing.roleTool' | 'billing.roleHint' | 'billing.tierPeak' | 'billing.tierOff' | 'billing.tierToPeak' | 'billing.tierToOff' | 'billing.tierAlertEnterPeak' | 'billing.tierAlertEnterOff' | 'billing.peakAlertTitlePeak' | 'billing.peakAlertTitleOff' | 'billing.peakAlert' | 'billing.peakAlertLeadMin' | 'billing.peakAlertPosCorner' | 'billing.peakAlertPosCenter' | 'billing.peakAlertModePeak' | 'billing.peakAlertModeOff' | 'billing.peakAlertModeBoth' | 'billing.peakAlertWebNotify' | 'billing.peakAlertPreview' | 'billing.planTypeCode' | 'billing.planTypeToken' | 'billing.subscriptionFeePerMonth' | 'billing.triggerToday' | 'billing.triggerMonth' | 'billing.subscriptionIncluded' | 'billing.free' | 'billing.official' | 'billing.thirdParty' | 'billing.officialCost' | 'billing.thirdPartyCost' | 'billing.perfSamples' | 'billing.perfTtft' | 'billing.perfP50' | 'billing.perfP90' | 'billing.perfTps' | 'billing.perfLatency' | 'billing.perfEstimated' | 'billing.perfEmpty' | 'billing.perfTpsUnit' | 'billing.perfTitle' | 'billing.perfHint' | 'billing.heatmapYear' | 'billing.heatmapMonth' | 'billing.activeDays' | 'billing.streakDays' | 'billing.subscriptionAutoDetect' | 'billing.pluginInfo' | 'billing.pluginName' | 'billing.pluginDescription' | 'billing.pluginVersion' | 'billing.pluginAuthor' | 'billing.pluginRepository' | 'billing.pluginNpm' | 'billing.pluginLicense' | 'billing.tabToken' | 'billing.tokenExport' | 'billing.tokenExportCsv' | 'billing.tokenCacheHitRate' | 'billing.tokenReasoningShare' | 'billing.tokenReasoningShort' | 'billing.tokenIo' | 'billing.tokenPeak' | 'billing.tokenDaily' | 'billing.tokenByModel' | 'billing.tokenMiss' | 'billing.tokenHit' | 'billing.tokenOutput' | 'billing.tokenTotal' | 'billing.tokenShare' | 'billing.usageStatsTool' | 'billing.usageStatsToolHint' | 'billing.balanceGranted' | 'billing.balanceTopped' | 'billing.balanceDaily' | 'billing.balanceDaysLong' | 'billing.balanceDaysUnit' | 'billing.popTodayModel' | 'billing.popNoConsumption' | 'billing.popQuotaAlert' | 'billing.popRiskNone' | 'billing.popTitle' | 'billing.popDirectLead' | 'billing.popSubLead' | 'billing.popBalanceNormal' | 'billing.popBalanceLow' | 'billing.popQuotaNormal' | 'billing.popQuotaLow' | 'billing.alertBalanceLow' | 'billing.alertQuotaLow' | 'billing.unpricedHint' | 'billing.exportCsvSite' | 'billing.panelRelay' | 'billing.relaySite' | 'billing.relayDirect' | 'billing.relayUnknown' | 'billing.panelRelayQuota' | 'billing.relayBalance' | 'billing.relayNoQuota' | 'billing.relayWindowUsed' | 'billing.relayKindNewApi' | 'billing.relayKindSub2Api' | 'billing.relayKindUnknown' | 'billing.relayCalls';
|
|
3
3
|
export declare const NS = "usageBilling";
|
|
4
4
|
export declare const zh: Record<UsageBillingKey, string>;
|
|
5
5
|
export declare const en: Record<UsageBillingKey, string>;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
* missing file answers `{ error }` so the dashboard shows zeros, never
|
|
10
10
|
* fabricated samples.
|
|
11
11
|
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
12
13
|
import type { Context } from '@deepseek-ai/cordis';
|
|
13
14
|
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials';
|
|
14
15
|
import { type SettingsProvider } from '@deepseek-ai/dsh-settings';
|
|
15
16
|
import type { CustomBalanceConfig, SubscriptionPlanConfig } from './pricing-shared.ts';
|
|
16
17
|
import { type IdentifiedSubscriptionPlan, type SubscriptionKeys } from './subscriptions.ts';
|
|
18
|
+
/**
|
|
19
|
+
* 回环防护守卫:仅接受回环 GET 请求(peer socket 地址 + Host 头同时校验)。
|
|
20
|
+
* 不满足时返回 403 并结束响应;调用方在 handler 顶部调用,返回 false 即已拒绝。
|
|
21
|
+
* @param req - 当前请求。
|
|
22
|
+
* @param res - 当前响应。
|
|
23
|
+
* @returns 是否放行;false = 已拒绝并结束响应。
|
|
24
|
+
*/
|
|
25
|
+
export declare function guardLoopback(req: IncomingMessage, res: ServerResponse): boolean;
|
|
17
26
|
/** Plugin configuration. */
|
|
18
27
|
export interface UsageBillingConfig {
|
|
19
28
|
/** Absolute path to a `.dsh-usage-stats.json` fallback file. */
|
|
@@ -123,6 +123,17 @@ export interface RelayQuota {
|
|
|
123
123
|
/** Response of `/api/billing/relay-quotas`. */
|
|
124
124
|
export interface RelayResponse {
|
|
125
125
|
quotas: readonly RelayQuota[];
|
|
126
|
+
/** 诊断:每条路由的归类(route / origin / kind / unknown),供「我的中转站为什么不显示」自查。 */
|
|
127
|
+
diagnostics?: readonly RelayDiagnostic[];
|
|
128
|
+
}
|
|
129
|
+
/** 一条路由的归类诊断(供面板/日志定位中转站识别结果)。 */
|
|
130
|
+
export interface RelayDiagnostic {
|
|
131
|
+
/** llm-pi-ai providers 路由名。 */
|
|
132
|
+
route: string;
|
|
133
|
+
/** 站点 origin(baseURL 归一化)。 */
|
|
134
|
+
origin: string;
|
|
135
|
+
/** 识别结果:new-api / sub2api(有额度读出)或 unknown(读不出)。 */
|
|
136
|
+
kind: 'new-api' | 'sub2api' | 'unknown';
|
|
126
137
|
}
|
|
127
138
|
/** Config for one subscription adapter (validated in apply). */
|
|
128
139
|
export interface SubscriptionPlanConfig {
|
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.10",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|