@kenz1117/dsh-ui-usage-billing 0.9.9 → 0.9.11

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
@@ -1129,6 +1129,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds, routes
1129
1129
  byDay: /* @__PURE__ */ new Map(),
1130
1130
  byDayModels: /* @__PURE__ */ new Map(),
1131
1131
  bySite: /* @__PURE__ */ new Map(),
1132
+ unpricedModels: /* @__PURE__ */ new Set(),
1132
1133
  planCalls: /* @__PURE__ */ new Map(),
1133
1134
  turns: [],
1134
1135
  perf: [],
@@ -1214,6 +1215,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds, routes
1214
1215
  if (usage === void 0) continue;
1215
1216
  const modelKey = key;
1216
1217
  const day = dayStamp(event.time);
1218
+ if (!subscription && !isPriced(modelKey)) fold.unpricedModels.add(modelKey);
1217
1219
  foldUsage(fold.total, usage, modelKey, subscription, event.time, official);
1218
1220
  foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time, official);
1219
1221
  foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
@@ -1402,6 +1404,7 @@ function createUsageAggregator(persistence, options = {}) {
1402
1404
  const byDay = /* @__PURE__ */ new Map();
1403
1405
  const byDayModels = /* @__PURE__ */ new Map();
1404
1406
  const bySite = /* @__PURE__ */ new Map();
1407
+ const unpricedModels = /* @__PURE__ */ new Set();
1405
1408
  const planCalls = /* @__PURE__ */ new Map();
1406
1409
  const sessionRows = [];
1407
1410
  const turnRows = [];
@@ -1425,6 +1428,7 @@ function createUsageAggregator(persistence, options = {}) {
1425
1428
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
1426
1429
  for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
1427
1430
  for (const [siteKey, cell] of fold.bySite) mergeUsageInto(usageCell(bySite, siteKey), cell);
1431
+ for (const id of fold.unpricedModels) unpricedModels.add(id);
1428
1432
  for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
1429
1433
  for (const sample of fold.perf) {
1430
1434
  let modelAccum = perfModel.get(sample.model);
@@ -1521,6 +1525,7 @@ function createUsageAggregator(persistence, options = {}) {
1521
1525
  byTurn: turnRows.slice(0, 200),
1522
1526
  byWorkspace: workspaces.slice(0, 100),
1523
1527
  ...bySite.size === 0 ? {} : { bySite: toRecord(bySite) },
1528
+ ...unpricedModels.size === 0 ? {} : { unpricedModels: [...unpricedModels].sort() },
1524
1529
  ...perf === void 0 ? {} : { perf },
1525
1530
  byRole: (() => {
1526
1531
  const chars = roles.userChars + roles.toolChars;
@@ -2911,6 +2916,10 @@ const relayGate = createCooldownGate({
2911
2916
  });
2912
2917
  /** Abort a relay fetch when the upstream hangs beyond this budget. */
2913
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();
2914
2923
  /** Number, or null when the value is not a finite number (nor numeric string). */
2915
2924
  function numberOrNull(value) {
2916
2925
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -3054,7 +3063,8 @@ async function queryRelayQuota(ctx, route) {
3054
3063
  origin: originOf(route.baseURL),
3055
3064
  displayName: route.displayName ?? route.route
3056
3065
  };
3057
- if (!relayGate.check(route.baseURL)) return {
3066
+ const gateKey = `${route.baseURL}::${route.apiKeyEnv}`;
3067
+ if (!relayGate.check(gateKey)) return {
3058
3068
  ...base,
3059
3069
  kind: "unknown",
3060
3070
  status: "unavailable"
@@ -3065,12 +3075,22 @@ async function queryRelayQuota(ctx, route) {
3065
3075
  kind: "unknown",
3066
3076
  status: "not-configured"
3067
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
+ };
3068
3084
  try {
3069
3085
  const sub2 = await fetchRelayJson(endpointOf(route.baseURL, "/v1/usage"), hit.value);
3070
3086
  if (sub2.ok) {
3071
3087
  const parsed = parseSub2ApiUsage(sub2.data);
3072
3088
  if (parsed !== null) {
3073
- relayGate.success(route.baseURL);
3089
+ relayGate.success(gateKey);
3090
+ fingerprintCache.set(base.origin, {
3091
+ kind: "sub2api",
3092
+ at: Date.now()
3093
+ });
3074
3094
  return {
3075
3095
  ...base,
3076
3096
  kind: "sub2api",
@@ -3079,7 +3099,7 @@ async function queryRelayQuota(ctx, route) {
3079
3099
  ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3080
3100
  };
3081
3101
  }
3082
- relayGate.fail(route.baseURL);
3102
+ relayGate.fail(gateKey);
3083
3103
  return {
3084
3104
  ...base,
3085
3105
  kind: "sub2api",
@@ -3087,7 +3107,7 @@ async function queryRelayQuota(ctx, route) {
3087
3107
  };
3088
3108
  }
3089
3109
  if (sub2.status === 401 || sub2.status === 403) {
3090
- relayGate.fail(route.baseURL);
3110
+ relayGate.fail(gateKey);
3091
3111
  return {
3092
3112
  ...base,
3093
3113
  kind: "unknown",
@@ -3096,7 +3116,7 @@ async function queryRelayQuota(ctx, route) {
3096
3116
  }
3097
3117
  const na = await fetchRelayJson(endpointOf(route.baseURL, "/api/status"), hit.value);
3098
3118
  if (na.status === 401 || na.status === 403) {
3099
- relayGate.fail(route.baseURL);
3119
+ relayGate.fail(gateKey);
3100
3120
  return {
3101
3121
  ...base,
3102
3122
  kind: "unknown",
@@ -3106,7 +3126,11 @@ async function queryRelayQuota(ctx, route) {
3106
3126
  if (na.ok) {
3107
3127
  const parsed = parseNewApiStatus(na.data);
3108
3128
  if (parsed !== null) {
3109
- relayGate.success(route.baseURL);
3129
+ relayGate.success(gateKey);
3130
+ fingerprintCache.set(base.origin, {
3131
+ kind: "new-api",
3132
+ at: Date.now()
3133
+ });
3110
3134
  return {
3111
3135
  ...base,
3112
3136
  kind: "new-api",
@@ -3114,21 +3138,21 @@ async function queryRelayQuota(ctx, route) {
3114
3138
  ...parsed.windows !== void 0 ? { windows: parsed.windows } : {}
3115
3139
  };
3116
3140
  }
3117
- relayGate.fail(route.baseURL);
3141
+ relayGate.fail(gateKey);
3118
3142
  return {
3119
3143
  ...base,
3120
3144
  kind: "new-api",
3121
3145
  status: "invalid-response"
3122
3146
  };
3123
3147
  }
3124
- relayGate.fail(route.baseURL);
3148
+ relayGate.fail(gateKey);
3125
3149
  return {
3126
3150
  ...base,
3127
3151
  kind: "unknown",
3128
3152
  status: "unavailable"
3129
3153
  };
3130
3154
  } catch (error) {
3131
- relayGate.fail(route.baseURL);
3155
+ relayGate.fail(gateKey);
3132
3156
  return {
3133
3157
  ...base,
3134
3158
  kind: "unknown",
@@ -3172,6 +3196,35 @@ const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
3172
3196
  * missing file answers `{ error }` so the dashboard shows zeros, never
3173
3197
  * fabricated samples.
3174
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
+ }
3175
3228
  /** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
3176
3229
  const usageBillingSettingsNs = settingsNamespace(BILLING_SETTINGS_NAMESPACE);
3177
3230
  /** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
@@ -3428,10 +3481,12 @@ function apply(ctx, config = {}) {
3428
3481
  "today",
3429
3482
  "month",
3430
3483
  "session",
3431
- "all"
3484
+ "all",
3485
+ "bySite",
3486
+ "relay"
3432
3487
  ],
3433
3488
  required: true,
3434
- description: "统计范围:today / month / session / all"
3489
+ description: "统计范围:today / month / session / all / bySite / relay"
3435
3490
  } },
3436
3491
  output: {
3437
3492
  schema: {
@@ -3484,6 +3539,26 @@ function apply(ctx, config = {}) {
3484
3539
  input: stats.total.input,
3485
3540
  output: stats.total.output
3486
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
+ }
3487
3562
  if (args.range === "today") {
3488
3563
  const day = stats.byDay[dayStamp(Date.now())];
3489
3564
  return day === void 0 ? zero : {
@@ -3556,7 +3631,8 @@ function apply(ctx, config = {}) {
3556
3631
  ctx.effect(() => ctx.webServer.register({
3557
3632
  kind: "exact",
3558
3633
  path: "/api/billing/pricing",
3559
- handler: async (_req, res) => {
3634
+ handler: async (req, res) => {
3635
+ if (!guardLoopback(req, res)) return;
3560
3636
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3561
3637
  res.end(JSON.stringify(live));
3562
3638
  }
@@ -3564,7 +3640,8 @@ function apply(ctx, config = {}) {
3564
3640
  ctx.effect(() => ctx.webServer.register({
3565
3641
  kind: "exact",
3566
3642
  path: "/api/billing/balance",
3567
- handler: async (_req, res) => {
3643
+ handler: async (req, res) => {
3644
+ if (!guardLoopback(req, res)) return;
3568
3645
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3569
3646
  const providers = { ...await readPiAiProviders(ctx.settings) };
3570
3647
  if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
@@ -3578,6 +3655,7 @@ function apply(ctx, config = {}) {
3578
3655
  kind: "exact",
3579
3656
  path: "/api/billing/usage-tool",
3580
3657
  handler: async (req, res) => {
3658
+ if (!guardLoopback(req, res)) return;
3581
3659
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3582
3660
  const enabled = usageSettingsScope?.get().enableUsageStatsTool ?? false;
3583
3661
  if (req.method === "GET") {
@@ -3642,7 +3720,8 @@ function apply(ctx, config = {}) {
3642
3720
  ctx.effect(() => ctx.webServer.register({
3643
3721
  kind: "exact",
3644
3722
  path: "/api/billing/subscriptions",
3645
- handler: async (_req, res) => {
3723
+ handler: async (req, res) => {
3724
+ if (!guardLoopback(req, res)) return;
3646
3725
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3647
3726
  if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) await refreshQuotas();
3648
3727
  res.end(JSON.stringify({ quotas: quotaCache.quotas }));
@@ -3672,16 +3751,26 @@ function apply(ctx, config = {}) {
3672
3751
  ctx.effect(() => ctx.webServer.register({
3673
3752
  kind: "exact",
3674
3753
  path: "/api/billing/relay-quotas",
3675
- handler: async (_req, res) => {
3754
+ handler: async (req, res) => {
3755
+ if (!guardLoopback(req, res)) return;
3676
3756
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3677
3757
  if (Date.now() - relayCache.at >= SUBSCRIPTION_CACHE_MS) await refreshRelay();
3678
- 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
+ }));
3679
3767
  }
3680
3768
  }), "usage-billing: relay-quotas route");
3681
3769
  ctx.effect(() => ctx.webServer.register({
3682
3770
  kind: "exact",
3683
3771
  path: "/api/billing/usage-stats",
3684
- handler: async (_req, res) => {
3772
+ handler: async (req, res) => {
3773
+ if (!guardLoopback(req, res)) return;
3685
3774
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3686
3775
  try {
3687
3776
  const stats = await aggregator.aggregate();
@@ -3714,4 +3803,4 @@ function apply(ctx, config = {}) {
3714
3803
  }), "usage-billing: usage-stats route");
3715
3804
  }
3716
3805
  //#endregion
3717
- 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.9",
4
+ "version": "0.9.11",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -78,13 +78,15 @@
78
78
  "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
79
79
  "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
80
80
  "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
81
- "@deepseek-ai/dsh-client-web-react": "workspace:^",
82
81
  "@deepseek-ai/dsh-invariants": "workspace:^",
83
82
  "@testing-library/react": "^16.1.0",
84
83
  "@types/react": "~18.3.1",
84
+ "@types/node": "^22.20.0",
85
85
  "@deepseek-ai/cordis": "workspace:^",
86
86
  "react": "^18.2.0",
87
- "react-dom": "^18.2.0"
87
+ "react-dom": "^18.2.0",
88
+ "lightningcss": "^1.32.0",
89
+ "tsdown": "^0.22.2"
88
90
  },
89
91
  "files": [
90
92
  "lib/index.js",