@kenz1117/dsh-ui-usage-billing 1.3.0 → 1.4.0

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
@@ -60,6 +60,7 @@ const PLAN_KNOWLEDGE = {
60
60
  "kimi-coding": { type: "code" },
61
61
  "zai-coding-cn": { type: "code" },
62
62
  "zai-coding": { type: "code" },
63
+ "commandcode": { type: "code" },
63
64
  "qwen-token-plan": { type: "code" },
64
65
  "qwen-token-plan-cn": { type: "code" },
65
66
  "xiaomi-token-plan-ams": { type: "code" },
@@ -4694,6 +4695,8 @@ const EMPTY_SUBSCRIPTION_KEYS = {
4694
4695
  opencodeApiKey: "",
4695
4696
  minmaxApiKey: "",
4696
4697
  openrouterApiKey: "",
4698
+ anthropicApiKey: "",
4699
+ commandcodeApiKey: "",
4697
4700
  tencentCloudApi: "",
4698
4701
  zaiRegion: "global"
4699
4702
  };
@@ -4720,11 +4723,12 @@ const SUBSCRIPTION_DISPLAY_NAMES = {
4720
4723
  "minimax-token-plan-cn": "MiniMax Token Plan(国内)",
4721
4724
  "minimax-cn": "MiniMax Token Plan(国内)",
4722
4725
  "openrouter": "OpenRouter",
4726
+ "commandcode": "CommandCode",
4723
4727
  "tencent-token-plan": "腾讯云 Token Plan",
4724
4728
  "grok": "Grok(X Premium)"
4725
4729
  };
4726
4730
  /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
4727
- const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-cn|minimax-token-plan|minimax-token-plan-cn|openrouter|grok)", "i");
4731
+ const SUBSCRIPTION_ID_RE = /* @__PURE__ */ new RegExp("(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax|minimax-cn|minimax-token-plan|minimax-token-plan-cn|openrouter|grok|commandcode)", "i");
4728
4732
  /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
4729
4733
  function isSubscriptionProviderId(providerId) {
4730
4734
  if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
@@ -4741,6 +4745,8 @@ const SUBSCRIPTION_ADAPTERS = {
4741
4745
  "minimax-token-plan": { collect: collectMiniMax },
4742
4746
  "minimax-token-plan-cn": { collect: collectMiniMax },
4743
4747
  "openrouter": { collect: collectOpenRouter },
4748
+ "anthropic": { collect: collectAnthropic },
4749
+ "commandcode": { collect: collectCommandCode },
4744
4750
  "tencent-token-plan": { collect: collectTencentTokenPlan }
4745
4751
  };
4746
4752
  /** 有额度适配器的 provider id 集合(识别用)。 */
@@ -5237,6 +5243,131 @@ async function collectOpenRouter(keys, config, timeoutMs) {
5237
5243
  };
5238
5244
  }
5239
5245
  }
5246
+ /** Parse one Anthropic usage window (`utilization` is already 0–100). */
5247
+ function anthropicWindow(value, kind) {
5248
+ if (value === null || typeof value !== "object") return null;
5249
+ const record = value;
5250
+ const utilization = numberOrNull$1(record.utilization ?? record.used_percentage);
5251
+ if (utilization === null) return null;
5252
+ const usedPercent = round1$1(clampPercent$1(utilization) ?? 0);
5253
+ const resetsAt = toIso(record.resets_at ?? record.reset_at);
5254
+ return {
5255
+ kind,
5256
+ usedPercent,
5257
+ remainingPercent: round1$1(100 - usedPercent),
5258
+ ...resetsAt === null ? {} : { resetsAt }
5259
+ };
5260
+ }
5261
+ /**
5262
+ * 解析 Anthropic OAuth 用量响应(GET https://api.anthropic.com/api/oauth/usage)。
5263
+ * 形如 `{ five_hour: { utilization, resets_at }, seven_day: {...}, seven_day_sonnet: {...} }`:
5264
+ * `utilization` 为 0–100 百分数,`resets_at` 为 unix 秒。子配额窗口
5265
+ * (`seven_day_sonnet` / `five_hour_opus` 等单模型系列限额)只描述一个模型分支,
5266
+ * 与主窗口量纲相同但口径更窄,整体丢弃,避免面板百分比被分支配额覆盖。
5267
+ * 导出供测试:纯函数。
5268
+ * @param body - 接口响应 JSON。
5269
+ * @returns 窗口列表(5 小时 → session、7 天 → weekly);无可用窗口时为 []。
5270
+ */
5271
+ function parseAnthropicUsage(body) {
5272
+ const doc = body ?? {};
5273
+ return [anthropicWindow(doc.five_hour, "session"), anthropicWindow(doc.seven_day, "weekly")].filter((hit) => hit !== null);
5274
+ }
5275
+ /** Collect the Claude Pro/Max subscription usage via the OAuth usage endpoint. */
5276
+ async function collectAnthropic(keys, config, timeoutMs) {
5277
+ const token = keys.anthropicApiKey.trim();
5278
+ const base = config.baseUrl ?? "https://api.anthropic.com";
5279
+ const displayName = "Claude (Anthropic)";
5280
+ if (token === "") return {
5281
+ provider: config.provider,
5282
+ displayName,
5283
+ status: "not-configured",
5284
+ windows: [],
5285
+ hint: "需 Claude Code 登录态(~/.claude/.credentials.json 自动读取)或 OAuth token;普通 sk-ant- 按量 API key 查不了订阅用量"
5286
+ };
5287
+ try {
5288
+ const windows = parseAnthropicUsage(await requestJson(`${base}/api/oauth/usage`, { headers: {
5289
+ authorization: `Bearer ${token}`,
5290
+ accept: "application/json"
5291
+ } }, timeoutMs));
5292
+ return {
5293
+ provider: config.provider,
5294
+ displayName,
5295
+ status: windows.length > 0 ? "ok" : "invalid-response",
5296
+ windows
5297
+ };
5298
+ } catch (error) {
5299
+ return {
5300
+ provider: config.provider,
5301
+ displayName,
5302
+ status: statusOf$1(error),
5303
+ windows: []
5304
+ };
5305
+ }
5306
+ }
5307
+ /** Parse one CommandCode window row (`used/cap`, `resetAt` is epoch ms). */
5308
+ function commandcodeWindow(value, kind) {
5309
+ if (value === null || typeof value !== "object") return null;
5310
+ const record = value;
5311
+ const used = numberOrNull$1(record.used);
5312
+ const cap = numberOrNull$1(record.cap ?? record.limit ?? record.total);
5313
+ if (used === null || cap === null || cap <= 0 || used < 0) return null;
5314
+ const usedPercent = round1$1(clampPercent$1(used / cap * 100) ?? 0);
5315
+ const resetsAt = toIso(record.resetAt ?? record.reset_at ?? record.resetsAt);
5316
+ return {
5317
+ kind,
5318
+ usedPercent,
5319
+ remainingPercent: round1$1(100 - usedPercent),
5320
+ ...resetsAt === null ? {} : { resetsAt }
5321
+ };
5322
+ }
5323
+ /**
5324
+ * 解析 CommandCode(commandcode.ai)额度响应
5325
+ * (GET https://api.commandcode.ai/alpha/billing/credits)。形如
5326
+ * `{ windowLimits: { fiveHour: { used, cap, resetAt }, weekly: {...} }, credits: { monthlyCredits } }`:
5327
+ * 窗口按 used/cap 算已用%(resetAt 为 epoch 毫秒);monthlyCredits 是月度
5328
+ * Credits 余额池(1 credit ≈ $1 用量),无总量字段、算不出百分比,不产出窗口。
5329
+ * 导出供测试:纯函数。
5330
+ * @param body - 接口响应 JSON。
5331
+ * @returns 窗口列表(5 小时 → session、周 → weekly);无可用窗口时为 []。
5332
+ */
5333
+ function parseCommandCodeCredits(body) {
5334
+ const limits = (body ?? {}).windowLimits;
5335
+ if (limits === null || typeof limits !== "object" || Array.isArray(limits)) return [];
5336
+ const record = limits;
5337
+ return [commandcodeWindow(record.fiveHour ?? record.five_hour, "session"), commandcodeWindow(record.weekly ?? record.week, "weekly")].filter((hit) => hit !== null);
5338
+ }
5339
+ /** Collect the CommandCode quota (5h/weekly windows + monthly credits). */
5340
+ async function collectCommandCode(keys, config, timeoutMs) {
5341
+ const apiKey = keys.commandcodeApiKey.trim();
5342
+ const base = config.baseUrl ?? "https://api.commandcode.ai";
5343
+ const displayName = SUBSCRIPTION_DISPLAY_NAMES["commandcode"] ?? "CommandCode";
5344
+ if (apiKey === "") return {
5345
+ provider: config.provider,
5346
+ displayName,
5347
+ status: "not-configured",
5348
+ windows: [],
5349
+ hint: "需 commandcode.ai 的 API key(user_ 前缀);可在 llm-pi-ai 给 commandcode 路由配 apiKeyEnv"
5350
+ };
5351
+ try {
5352
+ const windows = parseCommandCodeCredits(await requestJson(`${base}/alpha/billing/credits`, { headers: {
5353
+ authorization: `Bearer ${apiKey}`,
5354
+ accept: "application/json"
5355
+ } }, timeoutMs));
5356
+ return {
5357
+ provider: config.provider,
5358
+ displayName,
5359
+ status: windows.length > 0 ? "ok" : "invalid-response",
5360
+ windows
5361
+ };
5362
+ } catch (error) {
5363
+ return {
5364
+ provider: config.provider,
5365
+ displayName,
5366
+ status: statusOf$1(error),
5367
+ windows: []
5368
+ };
5369
+ }
5370
+ }
5240
5371
  /**
5241
5372
  * 腾讯云 Token Plan(TokenHub 管控面)订阅额度。凭据是云 API 密钥对
5242
5373
  * `<SecretId>:<SecretKey>`(与余额面板同源、同格式),链路与 balance.ts 的
@@ -5722,6 +5853,9 @@ const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESP
5722
5853
  const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
5723
5854
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
5724
5855
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
5856
+ /** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
5857
+ * 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
5858
+ const WARMUP_DELAY_MS = 3e3;
5725
5859
  /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
5726
5860
  const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
5727
5861
  const BALANCE_CACHE_MS = 300 * 1e3;
@@ -5874,6 +6008,10 @@ const SUBSCRIPTION_KEY_SOURCES = [
5874
6008
  provider: "openrouter",
5875
6009
  key: "openrouterApiKey"
5876
6010
  },
6011
+ {
6012
+ provider: "commandcode",
6013
+ key: "commandcodeApiKey"
6014
+ },
5877
6015
  {
5878
6016
  provider: "tencent-token-plan",
5879
6017
  key: "tencentCloudApi"
@@ -5981,6 +6119,19 @@ async function resolveSubscriptionKeys(settings, credentials) {
5981
6119
  }
5982
6120
  if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiApiKey !== "") keys.zaiRegion = "bigmodel-cn";
5983
6121
  if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
6122
+ keys.anthropicApiKey = await readClaudeOAuthToken();
6123
+ if (keys.anthropicApiKey !== "") {
6124
+ const identified = identifySubscriptionPlans(providers);
6125
+ if (!identified.some((plan) => plan.provider === "anthropic")) identified.push({
6126
+ provider: "anthropic",
6127
+ displayName: "Claude (Anthropic)",
6128
+ adapter: true
6129
+ });
6130
+ return {
6131
+ keys,
6132
+ identified
6133
+ };
6134
+ }
5984
6135
  return {
5985
6136
  keys,
5986
6137
  identified: identifySubscriptionPlans(providers)
@@ -6000,6 +6151,17 @@ async function readOpenCodeToken() {
6000
6151
  return "";
6001
6152
  }
6002
6153
  /**
6154
+ * 从本机 Claude Code 登录态自动发现 Anthropic OAuth access token;取不到返回
6155
+ * 空串(安静退回)。与 OpenCode auth.json 兜底同款姿态:只是一次便利,绝不报错。
6156
+ */
6157
+ async function readClaudeOAuthToken() {
6158
+ try {
6159
+ const token = JSON.parse(await readFile(join(homedir(), ".claude", ".credentials.json"), "utf8"))?.claudeAiOauth?.accessToken;
6160
+ if (typeof token === "string" && token !== "") return token;
6161
+ } catch {}
6162
+ return "";
6163
+ }
6164
+ /**
6003
6165
  * 宿主 persistence 形状适配。宿主 0.1.3 起 SessionPersistence 改为
6004
6166
  * SessionHandle 模型(open(id,'read') 后经 handle.read(offset) 读,fork 边界
6005
6167
  * 挂在 handle.inheritedEventCount,list 返回 {header, revision} 快照行,
@@ -6080,6 +6242,16 @@ function apply(ctx, config = {}) {
6080
6242
  ctx.effect(() => () => {
6081
6243
  aggregator.flush();
6082
6244
  }, "usage-billing: ledger flush on dispose");
6245
+ const warmupTimer = setTimeout(() => {
6246
+ aggregator.aggregate().then(() => {
6247
+ console.info("[usage-billing] historical replay warmed up; ledger ready");
6248
+ }).catch((error) => {
6249
+ console.warn("[usage-billing] historical replay warmup failed; will fold on first dashboard request:", error);
6250
+ });
6251
+ }, WARMUP_DELAY_MS);
6252
+ ctx.effect(() => () => {
6253
+ clearTimeout(warmupTimer);
6254
+ }, "usage-billing: historical replay warmup timer");
6083
6255
  const candidates = [
6084
6256
  config.statsPath,
6085
6257
  process.env.DSH_USAGE_STATS,
@@ -6328,8 +6500,10 @@ function apply(ctx, config = {}) {
6328
6500
  });
6329
6501
  });
6330
6502
  let live = { source: "builtin" };
6503
+ let pricingSyncedAt = 0;
6331
6504
  const refreshPricing = async () => {
6332
6505
  live = await fetchLivePricing();
6506
+ pricingSyncedAt = Date.now();
6333
6507
  applyLivePricing(live);
6334
6508
  };
6335
6509
  refreshPricing();
@@ -6347,9 +6521,40 @@ function apply(ctx, config = {}) {
6347
6521
  handler: async (req, res) => {
6348
6522
  if (!guardLoopback(req, res)) return;
6349
6523
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6350
- res.end(JSON.stringify(live));
6524
+ res.end(JSON.stringify({
6525
+ ...live,
6526
+ syncedAt: pricingSyncedAt
6527
+ }));
6351
6528
  }
6352
6529
  }), "usage-billing: pricing route");
6530
+ ctx.effect(() => ctx.webServer.register({
6531
+ kind: "exact",
6532
+ path: "/api/billing/pricing/refresh",
6533
+ handler: async (req, res) => {
6534
+ if (!guardLoopback(req, res)) return;
6535
+ if (req.method !== "POST") {
6536
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
6537
+ res.end(JSON.stringify({ error: "method not allowed" }));
6538
+ return;
6539
+ }
6540
+ if (!isLoopbackOrigin(req.headers.origin)) {
6541
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
6542
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
6543
+ return;
6544
+ }
6545
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
6546
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
6547
+ res.end(JSON.stringify({ error: "unsupported content-type" }));
6548
+ return;
6549
+ }
6550
+ await refreshPricing();
6551
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
6552
+ res.end(JSON.stringify({
6553
+ ...live,
6554
+ syncedAt: pricingSyncedAt
6555
+ }));
6556
+ }
6557
+ }), "usage-billing: pricing refresh route");
6353
6558
  let balanceCache = {
6354
6559
  at: 0,
6355
6560
  doc: { balances: [] }
@@ -27,6 +27,8 @@ export interface LivePricing {
27
27
  * 内置目录未收录」的模型也能计价并出现在费率表。
28
28
  */
29
29
  extraModels?: readonly ExtraModelPrice[];
30
+ /** 上次价格目录同步完成的本机时间戳(毫秒);0 = 尚未完成过任何一次同步。 */
31
+ syncedAt?: number;
30
32
  }
31
33
  /** models.dev 补充的目录外模型价(USD / 1M tokens)。 */
32
34
  export interface ExtraModelPrice {
@@ -24,6 +24,10 @@ export interface SubscriptionKeys {
24
24
  minmaxApiKey: string;
25
25
  /** OpenRouter API key(credits 已用%)。 */
26
26
  openrouterApiKey: string;
27
+ /** Anthropic Claude Pro/Max OAuth access token。 */
28
+ anthropicApiKey: string;
29
+ /** CommandCode API key(user_* 前缀)。 */
30
+ commandcodeApiKey: string;
27
31
  /** 腾讯云云 API 密钥对(`<SecretId>:<SecretKey>`,管控面用,非 TokenHub 推理 key)。 */
28
32
  tencentCloudApi: string;
29
33
  /** Z.ai 区域(global / bigmodel-cn)。 */
@@ -67,6 +71,28 @@ export declare function parseMiniMaxRemains(body: unknown): SubscriptionWindow[]
67
71
  * @returns 窗口列表;无有效额度时为 []。
68
72
  */
69
73
  export declare function parseOpenRouterCredits(body: unknown): SubscriptionWindow[];
74
+ /**
75
+ * 解析 Anthropic OAuth 用量响应(GET https://api.anthropic.com/api/oauth/usage)。
76
+ * 形如 `{ five_hour: { utilization, resets_at }, seven_day: {...}, seven_day_sonnet: {...} }`:
77
+ * `utilization` 为 0–100 百分数,`resets_at` 为 unix 秒。子配额窗口
78
+ * (`seven_day_sonnet` / `five_hour_opus` 等单模型系列限额)只描述一个模型分支,
79
+ * 与主窗口量纲相同但口径更窄,整体丢弃,避免面板百分比被分支配额覆盖。
80
+ * 导出供测试:纯函数。
81
+ * @param body - 接口响应 JSON。
82
+ * @returns 窗口列表(5 小时 → session、7 天 → weekly);无可用窗口时为 []。
83
+ */
84
+ export declare function parseAnthropicUsage(body: unknown): SubscriptionWindow[];
85
+ /**
86
+ * 解析 CommandCode(commandcode.ai)额度响应
87
+ * (GET https://api.commandcode.ai/alpha/billing/credits)。形如
88
+ * `{ windowLimits: { fiveHour: { used, cap, resetAt }, weekly: {...} }, credits: { monthlyCredits } }`:
89
+ * 窗口按 used/cap 算已用%(resetAt 为 epoch 毫秒);monthlyCredits 是月度
90
+ * Credits 余额池(1 credit ≈ $1 用量),无总量字段、算不出百分比,不产出窗口。
91
+ * 导出供测试:纯函数。
92
+ * @param body - 接口响应 JSON。
93
+ * @returns 窗口列表(5 小时 → session、周 → weekly);无可用窗口时为 []。
94
+ */
95
+ export declare function parseCommandCodeCredits(body: unknown): SubscriptionWindow[];
70
96
  /**
71
97
  * Collect quota for the given plans concurrently (adapter-backed plans only;
72
98
  * identified plans without an adapter are surfaced by the caller as "no
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-ui-usage-billing",
3
3
  "description": "Usage billing dashboard for DeepSeek Harness: sidebar cost metrics plus a full dashboard modal, priced from a current multi-provider catalog with real usage aggregated from session logs.",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "deepseek",