@kenz1117/dsh-ui-usage-billing 0.9.16 → 0.9.18

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
@@ -1057,11 +1057,58 @@ function hourStamp(time) {
1057
1057
  const pad = (n) => String(n).padStart(2, "0");
1058
1058
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}`;
1059
1059
  }
1060
+ /**
1061
+ * 宿主进程的 IANA 时区名与 UTC 偏移,供面板标注「天按哪个时区切分」。
1062
+ * `getTimezoneOffset` 是 UTC 以西的分钟数,符号与日常写法相反,故取反。
1063
+ * @returns `{ name, offset }`,如 `{ name: "Asia/Shanghai", offset: "UTC+08:00" }`。
1064
+ */
1065
+ function hostTimeZone(now = /* @__PURE__ */ new Date()) {
1066
+ const minutes = -now.getTimezoneOffset();
1067
+ const sign = minutes < 0 ? "-" : "+";
1068
+ const abs = Math.abs(minutes);
1069
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
1070
+ const mm = String(abs % 60).padStart(2, "0");
1071
+ let name;
1072
+ try {
1073
+ name = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1074
+ } catch {
1075
+ name = "UTC";
1076
+ }
1077
+ return {
1078
+ name,
1079
+ offset: `UTC${sign}${hh}:${mm}`
1080
+ };
1081
+ }
1060
1082
  /** 工作区名:取 cwd 的末级目录名;无 cwd 时返回 {@link UNKNOWN_WORKSPACE_NAME}。 */
1061
1083
  function workspaceNameOf(cwd) {
1062
1084
  if (cwd === void 0 || cwd === "") return "—";
1063
1085
  return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "—";
1064
1086
  }
1087
+ /**
1088
+ * 账本迁移注册表。当前账本 schema(version 1)尚无字段变更需求,故为空表;
1089
+ * 机制已就绪,schema 变更时在此登记幂等迁移,见 {@link LedgerMigration}。
1090
+ */
1091
+ const LEDGER_MIGRATIONS = [];
1092
+ /**
1093
+ * 在加载边界对账本文档应用未执行的迁移,并记录已应用 id 供写回。
1094
+ * @param document - 从持久化读出的原始账本文档。
1095
+ * @param migrations - 待执行的迁移注册表;缺省用模块级 {@link LEDGER_MIGRATIONS}。
1096
+ * @returns 是否发生了需要重新落盘的修改。
1097
+ */
1098
+ function runLedgerMigrations(document, migrations = LEDGER_MIGRATIONS) {
1099
+ const applied = new Set(document.appliedMigrations ?? []);
1100
+ let changed = false;
1101
+ for (const migration of migrations) {
1102
+ if (applied.has(migration.id)) continue;
1103
+ if (migration.apply(document)) changed = true;
1104
+ applied.add(migration.id);
1105
+ }
1106
+ if (applied.size > 0 && (document.appliedMigrations === void 0 || document.appliedMigrations.length !== applied.size || document.appliedMigrations.some((id) => !applied.has(id)))) {
1107
+ document.appliedMigrations = [...applied];
1108
+ changed = true;
1109
+ }
1110
+ return changed;
1111
+ }
1065
1112
  /** Serialize Map/Set-heavy fold state into a JSON-safe ledger entry. */
1066
1113
  function serializeFold(fold) {
1067
1114
  return {
@@ -1168,6 +1215,11 @@ function turnState(turns, turn) {
1168
1215
  * @returns the per-session fold (cached by the incremental aggregator).
1169
1216
  */
1170
1217
  function foldSession(events, subscriptionProviders, officialProviderIds, routes = {}) {
1218
+ let seedBoundary = -1;
1219
+ for (const event of events) if (event.type === "session/end-seed" && typeof event.seq === "number" && Number.isFinite(event.seq)) seedBoundary = Math.max(seedBoundary, event.seq);
1220
+ if (seedBoundary >= 0) {
1221
+ if (!events.some((event) => typeof event.seq === "number" && Number.isFinite(event.seq) && event.seq > seedBoundary)) seedBoundary = -1;
1222
+ }
1171
1223
  const fold = {
1172
1224
  total: emptyUsage(),
1173
1225
  byModel: /* @__PURE__ */ new Map(),
@@ -1194,6 +1246,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds, routes
1194
1246
  const steps = /* @__PURE__ */ new Map();
1195
1247
  let lastOpenStepKey;
1196
1248
  for (const event of events) {
1249
+ if (seedBoundary >= 0 && typeof event.seq === "number" && Number.isFinite(event.seq) && event.seq < seedBoundary) continue;
1197
1250
  fold.lastActive = Math.max(fold.lastActive, event.time);
1198
1251
  if (event.type === "session/title") {
1199
1252
  const title = event.data.title;
@@ -1395,6 +1448,9 @@ function createUsageAggregator(persistence, options = {}) {
1395
1448
  ledgerLoaded = true;
1396
1449
  try {
1397
1450
  const stored = await options.ledger.load();
1451
+ if (stored !== null && typeof stored === "object" && stored.sessions !== void 0) {
1452
+ if (runLedgerMigrations(stored)) ledgerNeedsSave = true;
1453
+ }
1398
1454
  for (const entry of ledgerSessionsOf(stored)) ledger.set(entry.id, entry);
1399
1455
  } catch (error) {
1400
1456
  console.warn("[usage-billing] failed to load durable usage ledger; rebuilding from current sessions:", error);
@@ -1625,6 +1681,7 @@ function createUsageAggregator(persistence, options = {}) {
1625
1681
  version: 3,
1626
1682
  updatedAt: now,
1627
1683
  source: "session-logs",
1684
+ timezone: hostTimeZone(),
1628
1685
  total,
1629
1686
  byModel: toRecord(byModel),
1630
1687
  byDay: toRecord(byDay),
@@ -1739,7 +1796,7 @@ function createCooldownGate(options = {}) {
1739
1796
  * provider's key once and every surface reuses it.
1740
1797
  */
1741
1798
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
1742
- const FETCH_TIMEOUT_MS$2 = 8e3;
1799
+ const FETCH_TIMEOUT_MS$3 = 8e3;
1743
1800
  /**
1744
1801
  * 每平台熔断门:单个 provider 连续可重试失败(网络波动 / 5xx / 429)达阈值后
1745
1802
  * 短路一段真实时间,避免 30 秒轮询在已不可用的上游上反复打满超时。
@@ -1767,7 +1824,7 @@ const XAI_CREDITS_URL = "https://api.x.ai/v1/billing/credits";
1767
1824
  /** 智谱 GLM(大模型国内域)官方余额接口(open.bigmodel.cn/api/paas/v4/balance)。 */
1768
1825
  const ZHIPU_BALANCE_URL = "https://open.bigmodel.cn/api/paas/v4/balance";
1769
1826
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
1770
- function toNumber(value) {
1827
+ function toNumber$1(value) {
1771
1828
  if (typeof value === "number" && Number.isFinite(value)) return value;
1772
1829
  if (typeof value === "string") {
1773
1830
  const parsed = Number(value);
@@ -1800,7 +1857,7 @@ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, pa
1800
1857
  };
1801
1858
  const doRequest = async () => {
1802
1859
  const controller = new AbortController();
1803
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
1860
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$3);
1804
1861
  try {
1805
1862
  const response = await fetch(url, {
1806
1863
  headers: {
@@ -1860,9 +1917,9 @@ function queryDeepSeek(ctx, apiKeyEnv) {
1860
1917
  const doc = data;
1861
1918
  const info = (Array.isArray(doc.balance_infos) ? doc.balance_infos : [])[0];
1862
1919
  const currency = typeof info?.currency === "string" ? info.currency : void 0;
1863
- const totalBalance = toNumber(info?.total_balance);
1864
- const grantedBalance = toNumber(info?.granted_balance);
1865
- const toppedUpBalance = toNumber(info?.topped_up_balance);
1920
+ const totalBalance = toNumber$1(info?.total_balance);
1921
+ const grantedBalance = toNumber$1(info?.granted_balance);
1922
+ const toppedUpBalance = toNumber$1(info?.topped_up_balance);
1866
1923
  const isAvailable = typeof doc.is_available === "boolean" ? doc.is_available : void 0;
1867
1924
  return {
1868
1925
  provider: "deepseek",
@@ -1884,9 +1941,9 @@ function queryDeepSeek(ctx, apiKeyEnv) {
1884
1941
  function queryMoonshot(ctx, apiKeyEnv) {
1885
1942
  return queryBearerBalance(ctx, MOONSHOT_BALANCE_URL, apiKeyEnv, "月之暗面", "月之暗面", (data) => {
1886
1943
  const doc = data;
1887
- const totalBalance = toNumber(doc.data?.available_balance);
1888
- const grantedBalance = toNumber(doc.data?.voucher_balance);
1889
- const toppedUpBalance = toNumber(doc.data?.cash_balance);
1944
+ const totalBalance = toNumber$1(doc.data?.available_balance);
1945
+ const grantedBalance = toNumber$1(doc.data?.voucher_balance);
1946
+ const toppedUpBalance = toNumber$1(doc.data?.cash_balance);
1890
1947
  return {
1891
1948
  provider: "月之暗面",
1892
1949
  displayName: "月之暗面",
@@ -1906,9 +1963,9 @@ function queryMoonshot(ctx, apiKeyEnv) {
1906
1963
  function queryStepFun(ctx, apiKeyEnv) {
1907
1964
  return queryBearerBalance(ctx, STEPFUN_BALANCE_URL, apiKeyEnv, "阶跃星辰", "阶跃星辰", (data) => {
1908
1965
  const doc = data;
1909
- const totalBalance = toNumber(doc.balance);
1910
- const toppedUpBalance = toNumber(doc.total_cash_balance);
1911
- const grantedBalance = toNumber(doc.total_voucher_balance);
1966
+ const totalBalance = toNumber$1(doc.balance);
1967
+ const toppedUpBalance = toNumber$1(doc.total_cash_balance);
1968
+ const grantedBalance = toNumber$1(doc.total_voucher_balance);
1912
1969
  return {
1913
1970
  provider: "阶跃星辰",
1914
1971
  displayName: "阶跃星辰",
@@ -1929,7 +1986,7 @@ function querySiliconFlow(ctx, apiKeyEnv) {
1929
1986
  return queryBearerBalance(ctx, SILICONFLOW_BALANCE_URL, apiKeyEnv, "硅基流动", "硅基流动", (data) => {
1930
1987
  const doc = data;
1931
1988
  const inner = doc.data ?? doc;
1932
- const totalBalance = toNumber(inner.balance ?? inner.balance_cny);
1989
+ const totalBalance = toNumber$1(inner.balance ?? inner.balance_cny);
1933
1990
  return {
1934
1991
  provider: "硅基流动",
1935
1992
  displayName: "硅基流动",
@@ -1946,7 +2003,7 @@ function querySiliconFlow(ctx, apiKeyEnv) {
1946
2003
  */
1947
2004
  function queryXai(ctx, apiKeyEnv) {
1948
2005
  return queryBearerBalance(ctx, XAI_CREDITS_URL, apiKeyEnv, "xAI", "xAI", (data) => {
1949
- const cents = toNumber(data.total?.val);
2006
+ const cents = toNumber$1(data.total?.val);
1950
2007
  const totalBalance = cents === void 0 ? void 0 : Math.abs(cents) / 100;
1951
2008
  return {
1952
2009
  provider: "xAI",
@@ -1968,7 +2025,7 @@ function queryXai(ctx, apiKeyEnv) {
1968
2025
  function queryZhipu(ctx, apiKeyEnv) {
1969
2026
  return queryBearerBalance(ctx, ZHIPU_BALANCE_URL, apiKeyEnv, "智谱 AI", "智谱 AI", (data) => {
1970
2027
  const doc = data;
1971
- const totalBalance = toNumber(doc.balance?.available) ?? toNumber(doc.balance?.total);
2028
+ const totalBalance = toNumber$1(doc.balance?.available) ?? toNumber$1(doc.balance?.total);
1972
2029
  return {
1973
2030
  provider: "智谱 AI",
1974
2031
  displayName: "智谱 AI",
@@ -2055,13 +2112,13 @@ function evalExtract(rule, data) {
2055
2112
  if (paths.length === 0) return void 0;
2056
2113
  let total;
2057
2114
  for (const path of paths) {
2058
- const value = toNumber(getPath(data, path));
2115
+ const value = toNumber$1(getPath(data, path));
2059
2116
  if (value === void 0) return void 0;
2060
2117
  total = total === void 0 ? value : rule.op === "add" ? total + value : total - value;
2061
2118
  }
2062
2119
  return total;
2063
2120
  }
2064
- const base = typeof rule.path === "string" ? toNumber(getPath(data, rule.path)) : void 0;
2121
+ const base = typeof rule.path === "string" ? toNumber$1(getPath(data, rule.path)) : void 0;
2065
2122
  if (base === void 0) return void 0;
2066
2123
  if (rule.op === "divide") {
2067
2124
  const by = rule.by;
@@ -2115,7 +2172,7 @@ async function queryCustomBalances(ctx, configs) {
2115
2172
  };
2116
2173
  const doRequest = async () => {
2117
2174
  const controller = new AbortController();
2118
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
2175
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$3);
2119
2176
  try {
2120
2177
  const response = await fetch(config.url, {
2121
2178
  method: config.method ?? "GET",
@@ -2170,6 +2227,383 @@ async function queryCustomBalances(ctx, configs) {
2170
2227
  }));
2171
2228
  }
2172
2229
  //#endregion
2230
+ //#region lib/types/declarative.js
2231
+ /**
2232
+ * 声明端点(declarative endpoints):让用户为内置表没有的供应商自声明余额/额度接口,
2233
+ * 不用等插件发版。用户提供「数字在哪儿」的取值路径,而非「怎么取」——没有表达式、
2234
+ * 没有任何东西被求值,`fields` / `windows` 里写的只是响应 JSON 的点路径,唯一操作是
2235
+ * 逐层下钻。
2236
+ *
2237
+ * ## 为什么把安全边界写在代码里而不是建议里
2238
+ *
2239
+ * 这个功能让**配置文件**决定一个携带用户 API key 的请求发往哪里。风险全在此,靠
2240
+ * 「提醒用户小心」一点都没用:
2241
+ *
2242
+ * 1. `origin` 只是查找键:请求 URL 由**匹配到的 provider 的 origin** 构造,绝不由
2243
+ * 声明的 origin 自己决定。匹配不到任何已配置的 provider 就不发请求。
2244
+ * 2. `path` 必须单斜杠绝对路径:`//evil.example/x` 是协议相对 URL,`new URL()` 会
2245
+ * 把它解析到别的主机;构造后还会再校验一次 origin。
2246
+ * 3. 只发 GET,无请求体,无自定义 method / headers。
2247
+ * 4. 凭据仍从匹配 provider 自己的 `apiKeyEnv` 取,经同一凭据 seam 解析;声明不能
2248
+ * 指定任何凭据。
2249
+ * 5. 跨源重定向直接失败,不跟随——那是绕过第 1 条最省事的办法。
2250
+ * 6. 响应体有大小上限与共享超时,坏 / 恶意端点拖不住面板。
2251
+ * 7. 声明不能覆盖内置读法:只在内置表答不上来时它才轮到。
2252
+ *
2253
+ * 这类行会标 `declared`,因为数字来自用户自己写的路径——取错是配置问题,界面要让
2254
+ * 这一点看得出来;全部字段都没取到时给 `reason`,而不是留一张和「上游没返回」无从
2255
+ * 区分的空卡。
2256
+ */
2257
+ /** Abort a declared fetch when the upstream hangs beyond this budget. */
2258
+ const FETCH_TIMEOUT_MS$2 = 8e3;
2259
+ /** 响应体上限(字节):坏 / 恶意端点不能拖住面板或耗尽内存。 */
2260
+ const MAX_BODY_BYTES = 1e6;
2261
+ /** 声明端点查询的熔断门:按(匹配 provider origin + path)独立熔断,各端点互不干扰。 */
2262
+ const declaredGate = createCooldownGate({
2263
+ failures: 3,
2264
+ cooldownMs: 6e4
2265
+ });
2266
+ /** 会触及原型链的路径段:一律拒绝(读结果不是文档自带字段)。 */
2267
+ const FORBIDDEN_SEGMENTS = new Set([
2268
+ "__proto__",
2269
+ "constructor",
2270
+ "prototype"
2271
+ ]);
2272
+ /**
2273
+ * 归一化 baseURL 为可比的 origin:`scheme://host[:port]`,剥掉 scheme 的默认端口,
2274
+ * host 转小写,忽略路径。无法解析返回 undefined。
2275
+ * @param baseURL - provider 的端点地址,或声明里的 origin。
2276
+ */
2277
+ function normalizeDeclaredOrigin(baseURL) {
2278
+ if (typeof baseURL !== "string" || baseURL.trim() === "") return void 0;
2279
+ let url;
2280
+ try {
2281
+ url = new URL(baseURL.trim());
2282
+ } catch {
2283
+ return;
2284
+ }
2285
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
2286
+ const defaultPort = url.protocol === "https:" ? "443" : "80";
2287
+ const port = url.port === "" || url.port === defaultPort ? "" : `:${url.port}`;
2288
+ return `${url.protocol}//${url.hostname.toLowerCase()}${port}`;
2289
+ }
2290
+ /**
2291
+ * 沿点路径走进已解析的响应体。
2292
+ * 任何失败都是同一个答案 `undefined`——路径不匹配是这份响应没有那个字段,是卡片
2293
+ * 本来就渲染得了的事实,不是错误,而且不该让已解析成功的字段被牵连。
2294
+ * @param body - 已解析的响应 JSON。
2295
+ * @param path - 点路径(如 `data.balance`)。
2296
+ * @returns 路径处的值,或 undefined(路径缺失 / 中途不是对象 / 命中原型链)。
2297
+ */
2298
+ function readDeclaredPath(body, path) {
2299
+ if (typeof path !== "string" || path === "") return void 0;
2300
+ let cursor = body;
2301
+ for (const segment of path.split(".")) {
2302
+ if (cursor === null || typeof cursor !== "object") return void 0;
2303
+ if (FORBIDDEN_SEGMENTS.has(segment)) return void 0;
2304
+ cursor = cursor[segment];
2305
+ }
2306
+ return cursor;
2307
+ }
2308
+ /** 数字常以字符串抵达(如 `"110.00"`),统一转 number;非有限数返回 undefined。 */
2309
+ function toNumber(value) {
2310
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2311
+ if (typeof value === "string" && value.trim() !== "") {
2312
+ const parsed = Number(value);
2313
+ if (Number.isFinite(parsed)) return parsed;
2314
+ }
2315
+ }
2316
+ /** 百分比收敛到 0–100。 */
2317
+ function clampPercent$2(value) {
2318
+ return Math.round(Math.max(0, Math.min(100, value)) * 10) / 10;
2319
+ }
2320
+ /** 解出一个窗口的已用百分比:usedPercent 路径 > usedRatio 路径 > used/limit 组合。 */
2321
+ function windowUsedPercent(body, input) {
2322
+ const ratio = typeof input.usedPercent === "string" ? toNumber(readDeclaredPath(body, input.usedPercent)) : void 0;
2323
+ if (ratio !== void 0) return clampPercent$2(ratio);
2324
+ const usageRatio = typeof input.usedRatio === "string" ? toNumber(readDeclaredPath(body, input.usedRatio)) : void 0;
2325
+ if (usageRatio !== void 0) return clampPercent$2(usageRatio * 100);
2326
+ const used = typeof input.used === "string" ? toNumber(readDeclaredPath(body, input.used)) : void 0;
2327
+ const total = typeof input.limit === "string" ? toNumber(readDeclaredPath(body, input.limit)) : void 0;
2328
+ if (used !== void 0 && total !== void 0 && total > 0) return clampPercent$2(used / total * 100);
2329
+ return null;
2330
+ }
2331
+ /**
2332
+ * 解出一个窗口的重置时刻:resetsAt 路径 > resetInSeconds 路径(now + 秒)推算。
2333
+ * @param body - 已解析的响应 JSON。
2334
+ * @param input - 窗口配置。
2335
+ * @param nowMs - 当前时刻(epoch ms)。
2336
+ * @returns ISO 时刻字符串,或 undefined。
2337
+ */
2338
+ function windowResetsAt(body, input, nowMs) {
2339
+ if (typeof input.resetsAt === "string") {
2340
+ const value = readDeclaredPath(body, input.resetsAt);
2341
+ if (typeof value === "string" && value !== "") return value;
2342
+ }
2343
+ if (typeof input.resetInSeconds === "string") {
2344
+ const seconds = toNumber(readDeclaredPath(body, input.resetInSeconds));
2345
+ if (seconds !== void 0 && Number.isFinite(seconds) && seconds >= 0) return new Date(nowMs + seconds * 1e3).toISOString();
2346
+ }
2347
+ }
2348
+ /** 把一条声明窗口配置解析成面板可渲染的滚动窗口;取不到已用百分比则不产出。 */
2349
+ function resolveDeclaredWindow(body, input, nowMs) {
2350
+ const used = windowUsedPercent(body, input);
2351
+ if (used === null) return null;
2352
+ const remaining = clampPercent$2(Math.max(0, 100 - used));
2353
+ const resetsAt = windowResetsAt(body, input, nowMs);
2354
+ const remainingAmount = typeof input.used === "string" && typeof input.limit === "string" ? (() => {
2355
+ const u = toNumber(readDeclaredPath(body, input.used));
2356
+ const t = toNumber(readDeclaredPath(body, input.limit));
2357
+ return u !== void 0 && t !== void 0 ? t - u : void 0;
2358
+ })() : void 0;
2359
+ return {
2360
+ kind: input.kind ?? "weekly",
2361
+ usedPercent: used,
2362
+ remainingPercent: remaining,
2363
+ ...resetsAt === void 0 ? {} : { resetsAt },
2364
+ ...remainingAmount === void 0 ? {} : { remaining: remainingAmount }
2365
+ };
2366
+ }
2367
+ /**
2368
+ * 校验并编译一条声明端点:能否安全地给出一条查询方案。
2369
+ * 返回方案的 `read` 用匹配到 provider 的 origin 构造 URL;路径不合规(非单斜杠绝对)
2370
+ * 返回 undefined,该账户保持「unsupported」,这是真话,也是它没声明之前的样子。
2371
+ */
2372
+ function compileDeclaredEndpoint(decl) {
2373
+ const path = decl?.path;
2374
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) return void 0;
2375
+ return path;
2376
+ }
2377
+ /**
2378
+ * 查询一组声明端点的余额/额度。每个声明独立成败,互不影响。
2379
+ * @param ctx - host context carrying the credentials seam.
2380
+ * @param providers - llm-pi-ai providers dict(`<route> → { baseURL?, apiKeyEnv? }`)。
2381
+ * @param declarations - 声明端点配置列表。
2382
+ * @returns 每个匹配到 provider 的声明一行结果;无匹配的声明不上报(不产生请求)。
2383
+ */
2384
+ async function queryDeclaredEndpoints(ctx, providers, declarations) {
2385
+ const byOrigin = /* @__PURE__ */ new Map();
2386
+ for (const entry of Object.values(providers)) {
2387
+ const origin = entry.baseURL === void 0 ? void 0 : normalizeDeclaredOrigin(entry.baseURL);
2388
+ if (origin !== void 0 && !byOrigin.has(origin)) byOrigin.set(origin, entry);
2389
+ }
2390
+ return await Promise.all(declarations.map(async (decl) => {
2391
+ const displayName = typeof decl.displayName === "string" && decl.displayName !== "" ? decl.displayName : "已声明";
2392
+ const provider = `declared:${displayName}`;
2393
+ const requestPath = compileDeclaredEndpoint(decl);
2394
+ if (requestPath === void 0) return {
2395
+ provider,
2396
+ displayName,
2397
+ declared: true,
2398
+ error: "invalid",
2399
+ reason: "path 必须单斜杠绝对路径"
2400
+ };
2401
+ const declOrigin = normalizeDeclaredOrigin(decl.origin);
2402
+ const matched = declOrigin === void 0 ? void 0 : byOrigin.get(declOrigin);
2403
+ if (matched === void 0) return {
2404
+ provider,
2405
+ displayName,
2406
+ declared: true,
2407
+ error: "unconfigured",
2408
+ reason: "未匹配到同源 provider(需先在 llm-pi-ai 配好 baseURL)"
2409
+ };
2410
+ const gateKey = `${declOrigin}${requestPath}`;
2411
+ if (!declaredGate.check(gateKey)) return {
2412
+ provider,
2413
+ displayName,
2414
+ declared: true,
2415
+ error: "unreachable"
2416
+ };
2417
+ const hit = await ctx.credentials.resolve(credentialRef(matched.apiKeyEnv ?? ""));
2418
+ if (hit === void 0 || hit.value === "") return {
2419
+ provider,
2420
+ displayName,
2421
+ declared: true,
2422
+ error: "unconfigured"
2423
+ };
2424
+ const url = new URL(requestPath, declOrigin);
2425
+ if (url.origin !== declOrigin) return {
2426
+ provider,
2427
+ displayName,
2428
+ declared: true,
2429
+ error: "invalid",
2430
+ reason: "跨源路径被拒绝"
2431
+ };
2432
+ try {
2433
+ const body = await fetchDeclaredBody(url, decl.raw === true, hit.value);
2434
+ const fields = decl.fields ?? {};
2435
+ const total = typeof fields.total === "string" ? toNumber(readDeclaredPath(body, fields.total)) : void 0;
2436
+ const granted = typeof fields.granted === "string" ? toNumber(readDeclaredPath(body, fields.granted)) : void 0;
2437
+ const used = typeof fields.used === "string" ? toNumber(readDeclaredPath(body, fields.used)) : void 0;
2438
+ const currency = typeof fields.currency === "string" ? readDeclaredPath(body, fields.currency) : void 0;
2439
+ const plan = typeof fields.plan === "string" ? readDeclaredPath(body, fields.plan) : void 0;
2440
+ const windows = (decl.windows ?? []).map((w) => resolveDeclaredWindow(body, w, Date.now())).filter((w) => w !== null);
2441
+ declaredGate.success(gateKey);
2442
+ const nothingResolved = total === void 0 && granted === void 0 && used === void 0 && windows.length === 0;
2443
+ return {
2444
+ provider,
2445
+ displayName,
2446
+ declared: true,
2447
+ ...total !== void 0 ? { totalBalance: total } : {},
2448
+ ...granted !== void 0 ? { grantedBalance: granted } : {},
2449
+ ...typeof currency === "string" && currency !== "" ? { currency } : {},
2450
+ ...typeof plan === "string" && plan !== "" ? { plan } : {},
2451
+ ...windows.length === 0 ? {} : { windows },
2452
+ ...nothingResolved ? {
2453
+ error: "invalid",
2454
+ reason: "声明路径未命中任何字段"
2455
+ } : {}
2456
+ };
2457
+ } catch {
2458
+ declaredGate.fail(gateKey);
2459
+ return {
2460
+ provider,
2461
+ displayName,
2462
+ declared: true,
2463
+ error: "unreachable"
2464
+ };
2465
+ }
2466
+ }));
2467
+ }
2468
+ /**
2469
+ * 带边界约束的 GET 请求:只 GET、跨源重定向直接失败、响应体上限、共享超时。
2470
+ * 401/403 → unauthorized(调用方判定);其余非 2xx / 超限抛错(走 unreachable)。
2471
+ */
2472
+ async function fetchDeclaredBody(url, raw, apiKey) {
2473
+ const doRequest = async () => {
2474
+ const controller = new AbortController();
2475
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$2);
2476
+ try {
2477
+ const response = await fetch(url.toString(), {
2478
+ method: "GET",
2479
+ headers: {
2480
+ accept: "application/json",
2481
+ ...raw ? {} : { authorization: `Bearer ${apiKey}` }
2482
+ },
2483
+ signal: controller.signal,
2484
+ redirect: "manual"
2485
+ });
2486
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) throw new HttpStatusError("redirect", 302);
2487
+ if (response.status === 401 || response.status === 403) throw new HttpStatusError("unauthorized", response.status);
2488
+ if (!response.ok) throw new HttpStatusError("http-error", response.status);
2489
+ const text = await response.text();
2490
+ if (text.length > MAX_BODY_BYTES) throw new HttpStatusError("body-too-large", 413);
2491
+ return JSON.parse(text);
2492
+ } finally {
2493
+ clearTimeout(timer);
2494
+ }
2495
+ };
2496
+ return await withRetry(doRequest, {
2497
+ retries: 1,
2498
+ baseDelayMs: 250,
2499
+ maxDelayMs: 2e3
2500
+ });
2501
+ }
2502
+ /** HTTP 状态错误:携带 statusCode,供调用方区分 unauthorized / 其它。 */
2503
+ var HttpStatusError = class extends Error {
2504
+ statusCode;
2505
+ kind;
2506
+ constructor(kind, statusCode) {
2507
+ super(`${kind}:${String(statusCode)}`);
2508
+ this.kind = kind;
2509
+ this.statusCode = statusCode;
2510
+ }
2511
+ };
2512
+ //#endregion
2513
+ //#region lib/types/reconcile.js
2514
+ /**
2515
+ * 余额差交叉校验(reconcile):用官方账户余额当日变动反推消费,与本地账本
2516
+ * 的当日官方费用比对。若二者偏差超阈值,提示用户核对价格表或近期账单——
2517
+ * 这是让计费数据可信的兜底机制,也是对账的稳定锚点。
2518
+ *
2519
+ * 只对官方直连渠道(DeepSeek 官方)对账:订阅 / Coding Plan / 第三方中转的
2520
+ * 消费不动官方余额,强行用余额差替代今日费用会把订阅用户全天的消费归零。
2521
+ * 充值、授信、币种变化都会让旧基准失去可比性,此时重置基准而非告警。
2522
+ *
2523
+ * 纯函数:输入上一基准与本次余额查询结果,输出新基准与对账事件,可独立单测。
2524
+ */
2525
+ /** 余额差对账阈值下限(元):消费低于该值时不值得提示,避免小金额波动刷屏。 */
2526
+ const MIN_DRIFT_ABS = .3;
2527
+ /** 余额差相对容忍度:偏差超过较大一方的该比例才判漂移。 */
2528
+ const DRIFT_RELATIVE = .15;
2529
+ /**
2530
+ * 归一化一个余额快照中的数值;非有限/缺失返回 0(对账只用其差值,0 安全)。
2531
+ * @param value - 待归一化的数值。
2532
+ */
2533
+ function num(value) {
2534
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2535
+ }
2536
+ /**
2537
+ * 由本次余额查询与上一基准推出一份新的基准快照(用于下一次对账)。
2538
+ * @param ref - 上一基准片段(date / currency / at)。
2539
+ * @param balance - 本次余额查询结果。
2540
+ * @param nowMs - 当前时刻。
2541
+ */
2542
+ function snapOf(ref, balance, nowMs) {
2543
+ return {
2544
+ date: ref.date,
2545
+ total: num(balance.totalBalance),
2546
+ granted: num(balance.grantedBalance),
2547
+ topped: num(balance.toppedUpBalance),
2548
+ currency: typeof balance.currency === "string" ? balance.currency : ref.currency,
2549
+ at: nowMs
2550
+ };
2551
+ }
2552
+ /**
2553
+ * 对账:用官方余额当日变动反推消费,与本地账本当日官方费用比对。
2554
+ * @param prevRef - 上一基准(可为 null,表示首次/基准缺失)。
2555
+ * @param balance - 本次余额查询结果。
2556
+ * @param todayOfficialCost - 本地账本当日的官方渠道费用(CNY)。
2557
+ * @param dayKey - 本地日期键(YYYY-MM-DD)。
2558
+ * @param nowMs - 当前时刻(epoch ms)。
2559
+ * @returns 新基准与对账事件;余额不可用(无 totalBalance)时返回 `{ ref: prevRef, event: null }`。
2560
+ */
2561
+ function reconcileBalanceDelta(prevRef, balance, todayOfficialCost, dayKey, nowMs) {
2562
+ const total = balance.totalBalance;
2563
+ if (typeof total !== "number" || !Number.isFinite(total)) return {
2564
+ ref: prevRef,
2565
+ event: null
2566
+ };
2567
+ const snap = snapOf({
2568
+ date: dayKey,
2569
+ currency: prevRef?.currency ?? ""
2570
+ }, balance, nowMs);
2571
+ if (prevRef === null || prevRef.date !== dayKey) return {
2572
+ ref: snap,
2573
+ event: { kind: "baseline" }
2574
+ };
2575
+ if (prevRef.currency !== snap.currency) return {
2576
+ ref: snap,
2577
+ event: { kind: "structure-reset" }
2578
+ };
2579
+ if (snap.granted > prevRef.granted + .009 || snap.topped > prevRef.topped + .009) return {
2580
+ ref: snap,
2581
+ event: { kind: "structure-reset" }
2582
+ };
2583
+ const spent = prevRef.total - snap.total;
2584
+ if (spent <= .009) return {
2585
+ ref: prevRef,
2586
+ event: { kind: "flat" }
2587
+ };
2588
+ const cost = num(todayOfficialCost);
2589
+ if (Math.abs(spent - cost) > Math.max(MIN_DRIFT_ABS, DRIFT_RELATIVE * Math.max(spent, cost))) return {
2590
+ ref: prevRef,
2591
+ event: {
2592
+ kind: "drift",
2593
+ spent,
2594
+ todayOfficialCost: cost
2595
+ }
2596
+ };
2597
+ return {
2598
+ ref: prevRef,
2599
+ event: {
2600
+ kind: "ok",
2601
+ spent,
2602
+ todayOfficialCost: cost
2603
+ }
2604
+ };
2605
+ }
2606
+ //#endregion
2173
2607
  //#region lib/types/pricing-fetch.js
2174
2608
  /**
2175
2609
  * One-shot live pricing refresh for the billing dashboard.
@@ -2813,7 +3247,8 @@ async function collectOpenCodeGo(keys, config, timeoutMs) {
2813
3247
  provider: config.provider,
2814
3248
  displayName: "OpenCode Go",
2815
3249
  status: "not-configured",
2816
- windows: []
3250
+ windows: [],
3251
+ hint: "未配置 key;可在 llm-pi-ai 里给 opencode(opencode-go) 配 apiKeyEnv,或让本机 OpenCode 凭据(~/.local/share/opencode/auth.json)可用"
2817
3252
  };
2818
3253
  try {
2819
3254
  const windows = parseOpenCodeGoApi(await requestJson(`${base}/zen/go/v1/usage`, { headers: {
@@ -2951,7 +3386,8 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2951
3386
  provider: config.provider,
2952
3387
  displayName: "OpenRouter",
2953
3388
  status: "not-configured",
2954
- windows: []
3389
+ windows: [],
3390
+ hint: "未配置 key;OpenRouter 的额度接口只认 Management Key,用推理 key 会 401"
2955
3391
  };
2956
3392
  try {
2957
3393
  const windows = parseOpenRouterCredits(await requestJson(`${base}/api/v1/credits`, { headers: {
@@ -2965,11 +3401,13 @@ async function collectOpenRouter(keys, config, timeoutMs) {
2965
3401
  windows
2966
3402
  };
2967
3403
  } catch (error) {
3404
+ const status = statusOf$1(error);
2968
3405
  return {
2969
3406
  provider: config.provider,
2970
3407
  displayName: "OpenRouter",
2971
- status: statusOf$1(error),
2972
- windows: []
3408
+ status,
3409
+ windows: [],
3410
+ ...status === "unauthorized" ? { hint: "OpenRouter 额度接口只认 Management Key;你配的 key 返回了 401,请换成管理密钥(在 openrouter.ai/settings/keys 创建)" } : {}
2973
3411
  };
2974
3412
  }
2975
3413
  }
@@ -3156,6 +3594,32 @@ function originOf(baseURL) {
3156
3594
  return baseURL;
3157
3595
  }
3158
3596
  }
3597
+ /**
3598
+ * 已知官方(非中转站)端点 host 判定:这些域是厂商自己的官方 API,卖的是
3599
+ * 官方按量余额而非中转站按 key 的额度,探测其 `/v1/usage`、`/api/status`
3600
+ * 子路径只会得到 404/非中转站格式,因而面板应排除它们,避免误判为
3601
+ * 「未识别」。中转站面板只列真正的第三方中转程序。
3602
+ */
3603
+ const OFFICIAL_HOSTS = new Set([
3604
+ "api.deepseek.com",
3605
+ "api.openai.com",
3606
+ "open.bigmodel.cn",
3607
+ "api.moonshot.cn",
3608
+ "api.siliconflow.cn",
3609
+ "api.stepfun.com",
3610
+ "api.x.ai",
3611
+ "api.anthropic.com",
3612
+ "generativelanguage.googleapis.com"
3613
+ ]);
3614
+ /**
3615
+ * 判断一个 baseURL 是否指向已知官方端点(而非第三方中转站)。
3616
+ * @param baseURL - llm-pi-ai provider 路由的端点地址。
3617
+ * @returns 官方端点返回 true(中转站面板应排除)。
3618
+ */
3619
+ function isOfficialBaseUrl(baseURL) {
3620
+ const host = originOf(baseURL).replace(/^https?:\/\//, "");
3621
+ return host !== baseURL && OFFICIAL_HOSTS.has(host);
3622
+ }
3159
3623
  /** 构造端点 URL:`/v1/usage` 与 `/api/status` 都以 baseURL 为宿主解析。 */
3160
3624
  function endpointOf(baseURL, path) {
3161
3625
  return new URL(path, baseURL).toString();
@@ -3540,11 +4004,25 @@ async function resolveSubscriptionKeys(settings, credentials) {
3540
4004
  } catch {}
3541
4005
  }
3542
4006
  if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiApiKey !== "") keys.zaiRegion = "bigmodel-cn";
4007
+ if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
3543
4008
  return {
3544
4009
  keys,
3545
4010
  identified: identifySubscriptionPlans(providers)
3546
4011
  };
3547
4012
  }
4013
+ /** 读本机 OpenCode 客户端的凭据 token;取不到返回空串(安静退回)。 */
4014
+ async function readOpenCodeToken() {
4015
+ try {
4016
+ const auth = JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8"));
4017
+ if (typeof auth === "string" && auth !== "") return auth;
4018
+ if (auth !== null && typeof auth === "object") {
4019
+ const record = auth;
4020
+ const token = record.token ?? record.key ?? record.apiKey;
4021
+ if (typeof token === "string" && token !== "") return token;
4022
+ }
4023
+ } catch {}
4024
+ return "";
4025
+ }
3548
4026
  /**
3549
4027
  * Host plugin body: serve real aggregated usage to the browser dashboard.
3550
4028
  * @param ctx - host context carrying webServer and sessionPersistence.
@@ -3553,8 +4031,26 @@ async function resolveSubscriptionKeys(settings, credentials) {
3553
4031
  function apply(ctx, config = {}) {
3554
4032
  let usageSettingsScope;
3555
4033
  const cwd = process.cwd();
3556
- const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
4034
+ const snapshotPath = config.snapshotPath ?? join(homedir(), ".dsh/.dsh-usage-stats.json");
3557
4035
  const ledgerStore = createFileUsageLedgerStore(config.ledgerPath ?? join(homedir(), ".dsh/.dsh-usage-ledger.json"));
4036
+ const reconcilePath = config.reconcilePath ?? join(homedir(), ".dsh/.dsh-usage-reconcile.json");
4037
+ let reconcileRef = null;
4038
+ (async () => {
4039
+ try {
4040
+ const ref = JSON.parse(await readFile(reconcilePath, "utf8")).ref;
4041
+ if (ref !== null && typeof ref === "object") {
4042
+ const candidate = ref;
4043
+ if (typeof candidate.date === "string" && typeof candidate.total === "number" && typeof candidate.granted === "number" && typeof candidate.topped === "number" && typeof candidate.currency === "string") reconcileRef = candidate;
4044
+ }
4045
+ } catch {}
4046
+ })();
4047
+ const persistReconcileRef = () => {
4048
+ if (reconcileRef === null) return;
4049
+ writeFileAtomic(reconcilePath, JSON.stringify({ ref: reconcileRef }), {
4050
+ mode: 384,
4051
+ dirMode: 448
4052
+ }).catch(() => {});
4053
+ };
3558
4054
  const workspaceTitleResolver = buildWorkspaceTitleResolver(ctx);
3559
4055
  const aggregator = createUsageAggregator(ctx.sessionPersistence, {
3560
4056
  ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders },
@@ -3785,12 +4281,42 @@ function apply(ctx, config = {}) {
3785
4281
  handler: async (req, res) => {
3786
4282
  if (!guardLoopback(req, res)) return;
3787
4283
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
3788
- const providers = { ...await readPiAiProviders(ctx.settings) };
4284
+ const piProviders = await readPiAiProviders(ctx.settings);
4285
+ const providers = { ...piProviders };
3789
4286
  if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
3790
4287
  const balances = await queryBalances(ctx, providers);
3791
4288
  const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
3792
- for (const row of [...balances, ...custom]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
3793
- res.end(JSON.stringify({ balances: [...balances, ...custom] }));
4289
+ const declared = await queryDeclaredEndpoints(ctx, piProviders, config.declaredEndpoints ?? []);
4290
+ for (const row of [
4291
+ ...balances,
4292
+ ...custom,
4293
+ ...declared
4294
+ ]) if (row.error === "unauthorized") warnAuthOnce("balance", row.provider, row.displayName);
4295
+ const official = balances.find((row) => row.provider === "deepseek");
4296
+ let reconcile;
4297
+ if (official !== void 0 && official.totalBalance !== void 0) {
4298
+ const now = Date.now();
4299
+ const today = dayStamp(now);
4300
+ let todayOfficialCost = 0;
4301
+ try {
4302
+ todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
4303
+ } catch {}
4304
+ const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
4305
+ reconcileRef = result.ref;
4306
+ if (result.event !== null && result.event.kind !== "flat") reconcile = {
4307
+ ...result.event,
4308
+ provider: official.displayName
4309
+ };
4310
+ if (result.ref !== null) persistReconcileRef();
4311
+ }
4312
+ res.end(JSON.stringify({
4313
+ balances: [
4314
+ ...balances,
4315
+ ...custom,
4316
+ ...declared
4317
+ ],
4318
+ ...reconcile === void 0 ? {} : { reconcile }
4319
+ }));
3794
4320
  }
3795
4321
  }), "usage-billing: balance route");
3796
4322
  ctx.effect(() => ctx.webServer.register({
@@ -3878,6 +4404,7 @@ function apply(ctx, config = {}) {
3878
4404
  const routes = [];
3879
4405
  for (const [route, entry] of Object.entries(providers)) {
3880
4406
  if (entry.baseURL === void 0 || entry.apiKeyEnv === void 0) continue;
4407
+ if (isOfficialBaseUrl(entry.baseURL)) continue;
3881
4408
  routes.push({
3882
4409
  route,
3883
4410
  baseURL: entry.baseURL,