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