@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/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
- /** Collect the MiniMax Token Plan quota. */
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.baseUrl ?? "https://www.minimaxi.com";
2788
+ const base = resolveMiniMaxBaseUrl(config);
2789
+ const displayName = minmaxDisplayName(config.provider);
2766
2790
  if (apiKey === "") return {
2767
2791
  provider: config.provider,
2768
- displayName: "MiniMax Coding Plan",
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: "MiniMax Coding Plan",
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: "MiniMax Coding Plan",
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
- if (!relayGate.check(route.baseURL)) return {
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(route.baseURL);
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(route.baseURL);
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(route.baseURL);
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(route.baseURL);
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(route.baseURL);
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(route.baseURL);
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(route.baseURL);
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(route.baseURL);
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 (_req, res) => {
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 (_req, res) => {
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 (_req, res) => {
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 (_req, res) => {
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
- res.end(JSON.stringify({ quotas: relayCache.quotas }));
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 (_req, res) => {
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 };
@@ -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>;
@@ -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.8",
4
+ "version": "0.9.10",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },