@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/README.en.md +4 -0
- package/README.md +4 -0
- package/lib/client.js +841 -618
- package/lib/index.js +553 -26
- package/lib/types/aggregate.d.ts +39 -0
- package/lib/types/client/TrendChart.d.ts +9 -3
- package/lib/types/client/UsageBilling.d.ts +13 -2
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/declarative.d.ts +55 -0
- package/lib/types/index.d.ts +13 -1
- package/lib/types/pricing-shared.d.ts +84 -0
- package/lib/types/reconcile.d.ts +60 -0
- package/lib/types/relay.d.ts +6 -0
- package/package.json +1 -1
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$
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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
|
-
|
|
3793
|
-
|
|
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,
|