@kenz1117/dsh-ui-usage-billing 1.5.1 → 1.6.1
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 +1 -1
- package/README.md +1 -1
- package/lib/client.js +5 -5
- package/lib/index.js +728 -420
- package/lib/types/client/TokenPanel.d.ts +1 -1
- package/lib/types/client/UsageBilling.d.ts +7 -347
- package/lib/types/client/api/balance.d.ts +24 -0
- package/lib/types/client/api/pricing.d.ts +26 -0
- package/lib/types/client/api/relay.d.ts +13 -0
- package/lib/types/client/api/subscriptions.d.ts +13 -0
- package/lib/types/client/api/usage-stats.d.ts +11 -0
- package/lib/types/client/api/usage-tool.d.ts +16 -0
- package/lib/types/client/budget-store.d.ts +6 -0
- package/lib/types/client/constants.d.ts +28 -0
- package/lib/types/client/domain/forecast.d.ts +24 -0
- package/lib/types/client/domain/host-version.d.ts +18 -0
- package/lib/types/client/domain/icons.d.ts +9 -0
- package/lib/types/client/domain/kpi.d.ts +34 -0
- package/lib/types/client/domain/model-budget.d.ts +13 -0
- package/lib/types/client/domain/notify.d.ts +14 -0
- package/lib/types/client/domain/provider-aliases.d.ts +28 -0
- package/lib/types/client/domain/provider-merge.d.ts +26 -0
- package/lib/types/client/domain/recharge.d.ts +15 -0
- package/lib/types/client/domain/recost.d.ts +10 -0
- package/lib/types/client/domain/session-viewer.d.ts +13 -0
- package/lib/types/client/domain/streak.d.ts +25 -0
- package/lib/types/client/domain/subscription-vendors.d.ts +11 -0
- package/lib/types/client/domain/types.d.ts +312 -0
- package/lib/types/client/domain/week.d.ts +35 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/settings-search.d.ts +12 -0
- package/lib/types/config.d.ts +79 -0
- package/lib/types/guard.d.ts +49 -0
- package/lib/types/index.d.ts +3 -100
- package/lib/types/routes/balance.d.ts +37 -0
- package/lib/types/routes/notify-claim.d.ts +24 -0
- package/lib/types/routes/pricing.d.ts +26 -0
- package/lib/types/routes/relay-quotas.d.ts +27 -0
- package/lib/types/routes/subscriptions.d.ts +28 -0
- package/lib/types/routes/usage-stats.d.ts +44 -0
- package/lib/types/routes/usage-tool.d.ts +27 -0
- package/lib/types/silent.d.ts +21 -0
- package/package.json +14 -7
package/lib/index.js
CHANGED
|
@@ -5157,7 +5157,11 @@ async function fetchDeclaredBody(url, raw, apiKey) {
|
|
|
5157
5157
|
if (!response.ok) throw new HttpStatusError("http-error", response.status);
|
|
5158
5158
|
const text = await response.text();
|
|
5159
5159
|
if (text.length > MAX_BODY_BYTES) throw new HttpStatusError("body-too-large", 413);
|
|
5160
|
-
|
|
5160
|
+
try {
|
|
5161
|
+
return JSON.parse(text);
|
|
5162
|
+
} catch (cause) {
|
|
5163
|
+
throw new HttpStatusError("parse-error", 0);
|
|
5164
|
+
}
|
|
5161
5165
|
} finally {
|
|
5162
5166
|
clearTimeout(timer);
|
|
5163
5167
|
}
|
|
@@ -5664,6 +5668,38 @@ async function fetchLivePricing() {
|
|
|
5664
5668
|
};
|
|
5665
5669
|
}
|
|
5666
5670
|
//#endregion
|
|
5671
|
+
//#region lib/types/silent.js
|
|
5672
|
+
/**
|
|
5673
|
+
* 静默吞错的结构化记录(S-6):「缺失=未配置」类 catch 的统一出口。
|
|
5674
|
+
*
|
|
5675
|
+
* 本仓 78 处 server 端 catch 多数是有意的静默(文件缺失=首次运行、凭据未
|
|
5676
|
+
* 配置=跳过该 provider),此前唯一载体是注释——用户报「X 不工作」时只能靠
|
|
5677
|
+
* 猜。本工具把这些静默升级为**节流的结构化日志**:同 reason 5 分钟最多一条
|
|
5678
|
+
* info,宿主日志可 grep `swallowed:`,诊断时看得到「哪个环节吞了什么」,
|
|
5679
|
+
* 而又不把已知常态刷成告警噪音。
|
|
5680
|
+
*
|
|
5681
|
+
* 不取代 console.warn——已知异常需要用户行动的(账本损坏、刷新失败)继续
|
|
5682
|
+
* warn;这里只收「确认无害的静默」。节流状态导出重置仅供测试。
|
|
5683
|
+
*/
|
|
5684
|
+
const THROTTLE_MS = 3e5;
|
|
5685
|
+
const lastLoggedAt = /* @__PURE__ */ new Map();
|
|
5686
|
+
/**
|
|
5687
|
+
* 记录一次有意的静默吞错。
|
|
5688
|
+
* @param reason - 稳定的短横线 reason 键(如 `reconcile-ref-missing`),节流以此为粒度。
|
|
5689
|
+
* @param error - 可选的原始异常(Error 打 message,其他值原样)。
|
|
5690
|
+
*/
|
|
5691
|
+
function swallowed(reason, error) {
|
|
5692
|
+
const now = Date.now();
|
|
5693
|
+
const last = lastLoggedAt.get(reason);
|
|
5694
|
+
if (last !== void 0 && now - last < THROTTLE_MS) return;
|
|
5695
|
+
lastLoggedAt.set(reason, now);
|
|
5696
|
+
if (error === void 0) {
|
|
5697
|
+
console.info(`[usage-billing] swallowed: ${reason}`);
|
|
5698
|
+
return;
|
|
5699
|
+
}
|
|
5700
|
+
console.info(`[usage-billing] swallowed: ${reason}`, error instanceof Error ? error.message : error);
|
|
5701
|
+
}
|
|
5702
|
+
//#endregion
|
|
5667
5703
|
//#region lib/types/subscriptions.js
|
|
5668
5704
|
/**
|
|
5669
5705
|
* Subscription-plan quota polling (node half): how much of each coding/token
|
|
@@ -6063,7 +6099,9 @@ async function collectZai(keys, config, timeoutMs) {
|
|
|
6063
6099
|
try {
|
|
6064
6100
|
subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
|
|
6065
6101
|
if (zaiEnvelopeStatus(subscription) !== void 0) subscription = null;
|
|
6066
|
-
} catch {
|
|
6102
|
+
} catch {
|
|
6103
|
+
swallowed("zai-plan-metadata-missing");
|
|
6104
|
+
}
|
|
6067
6105
|
const parsed = parseZai(quota, subscription);
|
|
6068
6106
|
return {
|
|
6069
6107
|
provider: config.provider,
|
|
@@ -6831,36 +6869,17 @@ async function queryRelayQuotas(ctx, routes) {
|
|
|
6831
6869
|
return await Promise.all(routes.map(async (route) => queryRelayQuota(ctx, route)));
|
|
6832
6870
|
}
|
|
6833
6871
|
//#endregion
|
|
6834
|
-
//#region lib/types/
|
|
6872
|
+
//#region lib/types/guard.js
|
|
6835
6873
|
/**
|
|
6836
|
-
*
|
|
6874
|
+
* 共享 loopback 守卫(v1.5.2 抽自 src/index.ts)。
|
|
6837
6875
|
*
|
|
6838
|
-
*
|
|
6839
|
-
*
|
|
6840
|
-
*
|
|
6841
|
-
*
|
|
6842
|
-
* 按宿主世代选择通道。缺省的默认行为是关闭——避免该工具默认占用模型每次请求
|
|
6843
|
-
* 的上下文(coding 场景通常在仪表盘看用量)。
|
|
6844
|
-
*/
|
|
6845
|
-
/** 设置命名空间 id(小写 kebab-case)。 */
|
|
6846
|
-
const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
|
|
6847
|
-
/** 该命名空间下用户可编辑的字段名。 */
|
|
6848
|
-
const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
6849
|
-
//#endregion
|
|
6850
|
-
//#region lib/types/index.js
|
|
6851
|
-
/**
|
|
6852
|
-
* Usage billing surface plugin, node half.
|
|
6876
|
+
* 端点只供本机浏览器用,局域网/远端请求一律拒绝,避免面板数据(含中转站
|
|
6877
|
+
* origin 与余额)外泄。三层防御:peer socket(最可信)+ Host 头精确字面量
|
|
6878
|
+
* (防 `127.0.0.1.evil.com` 这类 DNS rebinding 域名)+ trustedHosts 白名单
|
|
6879
|
+
* (反向代理场景的精确主机名放行)。Origin 头独立校验(写操作用)。
|
|
6853
6880
|
*
|
|
6854
|
-
*
|
|
6855
|
-
*
|
|
6856
|
-
* instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
|
|
6857
|
-
* requests get a short wait budget and fall back to the freshest persisted
|
|
6858
|
-
* snapshot while a slow full fold keeps running in the background (a heavy
|
|
6859
|
-
* user's first fold takes minutes and must not starve the single-process
|
|
6860
|
-
* host's RPC). When `sessionPersistence` is unavailable (or aggregation
|
|
6861
|
-
* fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
|
|
6862
|
-
* file is served as a fallback, and a missing file answers `{ error }` so the
|
|
6863
|
-
* dashboard shows zeros, never fabricated samples.
|
|
6881
|
+
* 设计要点:精确字面量匹配(`startsWith('127.')` 会被 `127.0.0.1.evil.com`
|
|
6882
|
+
* 穿透,必须正则)。IPv6 字面量带方括号(`[::1]:3080`)单独剥离。
|
|
6864
6883
|
*/
|
|
6865
6884
|
/** Peer 地址是否为回环(本地)。回环防护:本插件的端点只供本机浏览器用,
|
|
6866
6885
|
* 局域网/远端请求一律拒绝,避免面板数据(含中转站 origin 与余额)外泄。 */
|
|
@@ -6896,6 +6915,9 @@ function normalizeTrustedHosts(hosts) {
|
|
|
6896
6915
|
const names = hosts.filter((host) => typeof host === "string").map((host) => hostNameOf(host)).filter((host) => host !== "");
|
|
6897
6916
|
return new Set(names);
|
|
6898
6917
|
}
|
|
6918
|
+
/** 校验 Host 头是本机回环(精确 127.0.0.0/8 / ::1 / localhost 或空,供 curl 不带 Host 的极简请求)。
|
|
6919
|
+
* 拒绝 `127.0.0.1.attacker.com` 这类以 `127.` 开头但解析到外部的 DNS rebinding 域名:
|
|
6920
|
+
* 只用 `startsWith('127.')` 会被它穿透,必须精确匹配回环 IP 的字面量。 */
|
|
6899
6921
|
function isLoopbackHost(req, trustedHosts = /* @__PURE__ */ new Set()) {
|
|
6900
6922
|
const host = req.headers.host;
|
|
6901
6923
|
if (host === void 0 || host === "") return true;
|
|
@@ -6936,144 +6958,627 @@ function guardLoopback(req, res, trustedHosts = /* @__PURE__ */ new Set()) {
|
|
|
6936
6958
|
}
|
|
6937
6959
|
return true;
|
|
6938
6960
|
}
|
|
6961
|
+
//#endregion
|
|
6962
|
+
//#region lib/types/routes/notify-claim.js
|
|
6939
6963
|
/**
|
|
6940
|
-
*
|
|
6941
|
-
*
|
|
6942
|
-
*
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6964
|
+
* 路由:POST /api/billing/notify-claim
|
|
6965
|
+
*
|
|
6966
|
+
* 跨实例提醒认领(issue #44):多实例(CLI + 桌面等)合法共存,各自轮询
|
|
6967
|
+
* 会重复弹通知。两级去重——进程内 Set 挡同进程的并发/重渲染(trigger 与仪表盘
|
|
6968
|
+
* 是两个组件实例,effect 同周期并发时本地 store 标记读到的还是旧值);跨进程走
|
|
6969
|
+
* 认领端点(先到先得),另一实例刚发过则跳过。端点不可达(旧版本/网络故障)
|
|
6970
|
+
* 时放行,退化为原有 per-客户端去重。通知构造失败(平台限制)静默。
|
|
6971
|
+
*
|
|
6972
|
+
* 路由文件职责:handler + 模块级 `notifyClaims` 单例(与 `loadNotifications`
|
|
6973
|
+
* 共享内存状态——先到先得认领)。文件持久化(`writeFileAtomic`)由 index.ts 装配
|
|
6974
|
+
* 器通过 `claimsPath` 注入;handler 拿到路径后自行管理读写。
|
|
6975
|
+
*/
|
|
6976
|
+
/** 模块级认领表:loaded 表示是否已从磁盘反序列化;keys 是先到先得序。 */
|
|
6977
|
+
const notifyClaims = {
|
|
6978
|
+
loaded: false,
|
|
6979
|
+
keys: []
|
|
6980
|
+
};
|
|
6981
|
+
/** 注册路由到 cordis webServer effect;handler 闭包外只读 deps。 */
|
|
6982
|
+
function registerNotifyClaimRoute(ctx, deps) {
|
|
6983
|
+
ctx.effect(() => ctx.webServer.register({
|
|
6984
|
+
kind: "exact",
|
|
6985
|
+
path: "/api/billing/notify-claim",
|
|
6986
|
+
handler: async (req, res) => {
|
|
6987
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
6988
|
+
if (req.method !== "POST") {
|
|
6989
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
6990
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
6991
|
+
return;
|
|
6992
|
+
}
|
|
6993
|
+
if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
6994
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
6995
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
6996
|
+
return;
|
|
6997
|
+
}
|
|
6998
|
+
try {
|
|
6999
|
+
let body = "";
|
|
7000
|
+
for await (const chunk of req) {
|
|
7001
|
+
body += String(chunk);
|
|
7002
|
+
if (body.length > 2048) {
|
|
7003
|
+
res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
|
|
7004
|
+
res.end(JSON.stringify({ error: "body too large" }));
|
|
7005
|
+
return;
|
|
7006
|
+
}
|
|
7007
|
+
}
|
|
7008
|
+
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
7009
|
+
if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
|
|
7010
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7011
|
+
res.end(JSON.stringify({ error: "invalid key" }));
|
|
7012
|
+
return;
|
|
7013
|
+
}
|
|
7014
|
+
if (!notifyClaims.loaded) {
|
|
7015
|
+
notifyClaims.loaded = true;
|
|
7016
|
+
try {
|
|
7017
|
+
const stored = JSON.parse(await readFile(deps.claimsPath, "utf8"));
|
|
7018
|
+
if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
|
|
7019
|
+
} catch {
|
|
7020
|
+
swallowed("notify-claims-file-unreadable");
|
|
7021
|
+
}
|
|
7022
|
+
}
|
|
7023
|
+
if (notifyClaims.keys.includes(parsed.key)) {
|
|
7024
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7025
|
+
res.end(JSON.stringify({ claimed: false }));
|
|
7026
|
+
return;
|
|
7027
|
+
}
|
|
7028
|
+
notifyClaims.keys.push(parsed.key);
|
|
7029
|
+
if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
|
|
7030
|
+
writeFileAtomic(deps.claimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
|
|
7031
|
+
mode: 384,
|
|
7032
|
+
dirMode: 448
|
|
7033
|
+
}).catch(() => {});
|
|
7034
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7035
|
+
res.end(JSON.stringify({ claimed: true }));
|
|
7036
|
+
} catch {
|
|
7037
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7038
|
+
res.end(JSON.stringify({ error: "invalid" }));
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
}), "usage-billing: notify-claim route");
|
|
6949
7042
|
}
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
/** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
|
|
6953
|
-
const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
|
|
6954
|
-
/**
|
|
6955
|
-
* Loader 挂载时对条目 config 做校验的 Config 声明:`enableUsageStatsTool` 标记
|
|
6956
|
-
* volatile,宿主 0.1.7+ 设置系统据此把它投影为可实时编辑的开关(describe/update)。
|
|
6957
|
-
* schemastery 非严格 object 透传未声明键,其余 config 字段不受影响;未导出 Config
|
|
6958
|
-
* 的旧宿主直接把原始 config 交给 apply,普通布尔形态由读取端兼容。
|
|
6959
|
-
*/
|
|
6960
|
-
const Config = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false).volatile() });
|
|
6961
|
-
/** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
|
|
6962
|
-
const PRICING_REFRESH_INTERVAL_MS = 216e5;
|
|
6963
|
-
/** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
|
|
6964
|
-
* 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
|
|
6965
|
-
const WARMUP_DELAY_MS = 3e3;
|
|
6966
|
-
/** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
|
|
6967
|
-
const SUBSCRIPTION_CACHE_MS = 3e5;
|
|
6968
|
-
const BALANCE_CACHE_MS = 3e5;
|
|
6969
|
-
/** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
|
|
6970
|
-
const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
6971
|
-
/**
|
|
6972
|
-
* 本插件版本号:从包自身的 package.json 读取(单一来源),随 usage-stats
|
|
6973
|
-
* 下发,供「设置 → 插件信息卡」展示。发布版 lib/index.js 相对包根解析。
|
|
6974
|
-
*/
|
|
6975
|
-
const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
|
|
6976
|
-
/** 统计快照的落盘节流(毫秒):快照只服务聚合失败回退与重启首屏(展示级
|
|
6977
|
-
* 新鲜度即可),降到 5 分钟一次——原子写含上一版备份是两次全量 JSON 落盘,
|
|
6978
|
-
* 重度用户快照上百 KB,跟随前端轮询 30 秒写一次纯属写放大。 */
|
|
6979
|
-
const SNAPSHOT_INTERVAL_MS = 3e5;
|
|
6980
|
-
/** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
|
|
6981
|
-
* 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
|
|
6982
|
-
* 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
|
|
6983
|
-
const RESPONSE_BUDGET_MS = 1500;
|
|
6984
|
-
/** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
|
|
6985
|
-
const AUTH_WARN_COOLDOWN_MS = 18e5;
|
|
7043
|
+
//#endregion
|
|
7044
|
+
//#region lib/types/routes/pricing.js
|
|
6986
7045
|
/**
|
|
6987
|
-
*
|
|
6988
|
-
*
|
|
7046
|
+
* 路由:GET /api/billing/pricing + POST /api/billing/pricing/refresh
|
|
7047
|
+
*
|
|
7048
|
+
* 实时定价(汇率 + OpenRouter 模型价 + models.dev 目录外补充)的 GET/POST 同
|
|
7049
|
+
* concern,合一个文件:
|
|
7050
|
+
* - GET 由前端 30s 轮询 + 弹窗初始拉取触发,返回完整价目表文档(live + 内置
|
|
7051
|
+
* 目录 + 别名表 + syncedAt)。
|
|
7052
|
+
* - POST 是费率条「立即同步」按钮触发,强制服务端重新拉取一次;写操作额外
|
|
7053
|
+
* 校验 Origin 与 Content-Type。
|
|
7054
|
+
*
|
|
7055
|
+
* 路由文件只关心 HTTP 层:响应体序列化与缓存(pricingBodyCache)由 index.ts
|
|
7056
|
+
* 装配器通过 `body()` 闭包注入;服务端重拉逻辑(refreshPricing + 价格目录代际
|
|
7057
|
+
* 变化触发 reprice)也在装配器里,路由只通过 `refresh()` 触发。
|
|
6989
7058
|
*/
|
|
6990
|
-
|
|
6991
|
-
function
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
7059
|
+
/** GET /api/billing/pricing。 */
|
|
7060
|
+
function registerGetRoute(ctx, deps) {
|
|
7061
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7062
|
+
kind: "exact",
|
|
7063
|
+
path: "/api/billing/pricing",
|
|
7064
|
+
handler: async (req, res) => {
|
|
7065
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7066
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7067
|
+
res.end(deps.body());
|
|
7068
|
+
}
|
|
7069
|
+
}), "usage-billing: pricing route");
|
|
6998
7070
|
}
|
|
6999
|
-
/**
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7071
|
+
/** POST /api/billing/pricing/refresh。 */
|
|
7072
|
+
function registerRefreshRoute(ctx, deps) {
|
|
7073
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7074
|
+
kind: "exact",
|
|
7075
|
+
path: "/api/billing/pricing/refresh",
|
|
7076
|
+
handler: async (req, res) => {
|
|
7077
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7078
|
+
if (req.method !== "POST") {
|
|
7079
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7080
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
7083
|
+
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7084
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7085
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7086
|
+
return;
|
|
7087
|
+
}
|
|
7088
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7089
|
+
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7090
|
+
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7091
|
+
return;
|
|
7092
|
+
}
|
|
7093
|
+
await deps.refresh();
|
|
7094
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7095
|
+
res.end(deps.body());
|
|
7096
|
+
}
|
|
7097
|
+
}), "usage-billing: pricing refresh route");
|
|
7098
|
+
}
|
|
7099
|
+
/** 一次性注册两条同 concern 路由。 */
|
|
7100
|
+
function registerPricingRoutes(ctx, deps) {
|
|
7101
|
+
registerGetRoute(ctx, deps);
|
|
7102
|
+
registerRefreshRoute(ctx, deps);
|
|
7024
7103
|
}
|
|
7104
|
+
//#endregion
|
|
7105
|
+
//#region lib/types/routes/balance.js
|
|
7025
7106
|
/**
|
|
7026
|
-
*
|
|
7027
|
-
*
|
|
7107
|
+
* 路由:GET /api/billing/balance
|
|
7108
|
+
*
|
|
7109
|
+
* 多平台余额(DeepSeek / Kimi / StepFun / SiliconFlow / xAI / 智谱 / 腾讯云
|
|
7110
|
+
* TokenHub 等)+ 用户自定义余额 + 声明端点 + 余额差对账。5 分钟缓存(避免
|
|
7111
|
+
* 每 30s 后台轮询打上游),in-flight 去重(并发请求共享同一次进行中的刷新)。
|
|
7112
|
+
*
|
|
7113
|
+
* 路由文件只关心 HTTP 缓存逻辑:5 分钟过期判定 + 失败时仍发旧缓存(不挂起
|
|
7114
|
+
* 已 writeHead 的响应)。上游查询(queryBalances / queryCustomBalances /
|
|
7115
|
+
* queryDeclaredEndpoints / reconcileBalanceDelta)在装配器里跑,路由只通过
|
|
7116
|
+
* `cached()` 与 `refreshOnce()` 拿到结果。
|
|
7028
7117
|
*/
|
|
7029
|
-
function
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
async save(document) {
|
|
7041
|
-
await mkdir(dirname(ledgerPath), {
|
|
7042
|
-
recursive: true,
|
|
7043
|
-
mode: 448
|
|
7044
|
-
});
|
|
7045
|
-
const commit = async () => {
|
|
7046
|
-
let existing;
|
|
7047
|
-
let existingDoc;
|
|
7048
|
-
try {
|
|
7049
|
-
existing = await readFile(ledgerPath, "utf8");
|
|
7050
|
-
const parsed = JSON.parse(existing);
|
|
7051
|
-
if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
|
|
7052
|
-
} catch {
|
|
7053
|
-
existing = void 0;
|
|
7054
|
-
}
|
|
7055
|
-
if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
|
|
7056
|
-
mode: 384,
|
|
7057
|
-
dirMode: 448
|
|
7058
|
-
});
|
|
7059
|
-
const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
|
|
7060
|
-
await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
|
|
7061
|
-
mode: 384,
|
|
7062
|
-
dirMode: 448
|
|
7063
|
-
});
|
|
7064
|
-
};
|
|
7118
|
+
function registerBalanceRoute(ctx, deps) {
|
|
7119
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7120
|
+
kind: "exact",
|
|
7121
|
+
path: "/api/billing/balance",
|
|
7122
|
+
handler: async (req, res) => {
|
|
7123
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7124
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7125
|
+
if (Date.now() - deps.cached().at < deps.cacheMs) {
|
|
7126
|
+
res.end(JSON.stringify(deps.cached().doc));
|
|
7127
|
+
return;
|
|
7128
|
+
}
|
|
7065
7129
|
try {
|
|
7066
|
-
await
|
|
7130
|
+
await deps.refreshOnce();
|
|
7067
7131
|
} catch (error) {
|
|
7068
|
-
|
|
7069
|
-
if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
|
|
7070
|
-
await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
|
|
7132
|
+
console.warn("[usage-billing] refreshBalances failed; serving cached balances:", error);
|
|
7071
7133
|
}
|
|
7134
|
+
res.end(JSON.stringify(deps.cached().doc));
|
|
7072
7135
|
}
|
|
7073
|
-
};
|
|
7136
|
+
}), "usage-billing: balance route");
|
|
7074
7137
|
}
|
|
7075
|
-
|
|
7076
|
-
|
|
7138
|
+
//#endregion
|
|
7139
|
+
//#region lib/types/routes/usage-tool.js
|
|
7140
|
+
/**
|
|
7141
|
+
* 路由:GET/POST /api/billing/usage-tool
|
|
7142
|
+
*
|
|
7143
|
+
* usage_stats 工具开关:插件自带的读/写接口(不依赖宿主浏览器设置白名单)。
|
|
7144
|
+
* GET 返回当前是否注入;POST 写设置命名空间(重启应用后工具注入随新值生效)。
|
|
7145
|
+
*
|
|
7146
|
+
* 双世代宿主(issue #19 + #28 + v1.4.19)的接线在装配器里完成:
|
|
7147
|
+
* - 旧宿主 ≤0.1.6 走 `settings.register` 命名空间 scope。
|
|
7148
|
+
* - 宿主 0.1.7+ 注册面已移除,走 volatile config 投影。
|
|
7149
|
+
* 装配器提供 `read` / `write` 闭包,路由文件不感知双世代——`read()` 已内部分支
|
|
7150
|
+
* (scope 存在时读 scope,否则读 volatile + config base),`write(next)` 同理。
|
|
7151
|
+
* 工具注入由装配器的 `syncUsageTool` 驱动:写回后路由通过 `syncTool()` 显式调用一次
|
|
7152
|
+
* (幂等),覆盖「写回成功但引用未即时可见」的组合(测试替身不提交)。
|
|
7153
|
+
*/
|
|
7154
|
+
function registerUsageToolRoute(ctx, deps) {
|
|
7155
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7156
|
+
kind: "exact",
|
|
7157
|
+
path: "/api/billing/usage-tool",
|
|
7158
|
+
handler: async (req, res) => {
|
|
7159
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7160
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
7161
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7162
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7163
|
+
return;
|
|
7164
|
+
}
|
|
7165
|
+
if (req.method === "POST") {
|
|
7166
|
+
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7167
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7168
|
+
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7169
|
+
return;
|
|
7170
|
+
}
|
|
7171
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7172
|
+
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7173
|
+
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7174
|
+
return;
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7178
|
+
if (req.method === "GET") {
|
|
7179
|
+
res.end(JSON.stringify({ enabled: deps.read() }));
|
|
7180
|
+
return;
|
|
7181
|
+
}
|
|
7182
|
+
try {
|
|
7183
|
+
let body = "";
|
|
7184
|
+
for await (const chunk of req) {
|
|
7185
|
+
body += String(chunk);
|
|
7186
|
+
if (body.length > 512) {
|
|
7187
|
+
res.writeHead(413, { "content-type": "application/json; charset=utf-8" });
|
|
7188
|
+
res.end(JSON.stringify({ error: "body too large" }));
|
|
7189
|
+
return;
|
|
7190
|
+
}
|
|
7191
|
+
}
|
|
7192
|
+
const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
|
|
7193
|
+
await deps.write(next);
|
|
7194
|
+
deps.syncTool();
|
|
7195
|
+
res.end(JSON.stringify({
|
|
7196
|
+
ok: true,
|
|
7197
|
+
enabled: next
|
|
7198
|
+
}));
|
|
7199
|
+
} catch (error) {
|
|
7200
|
+
console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
|
|
7201
|
+
res.end(JSON.stringify({ error: "invalid" }));
|
|
7202
|
+
}
|
|
7203
|
+
}
|
|
7204
|
+
}), "usage-billing: usage-tool route");
|
|
7205
|
+
}
|
|
7206
|
+
//#endregion
|
|
7207
|
+
//#region lib/types/routes/subscriptions.js
|
|
7208
|
+
/**
|
|
7209
|
+
* 路由:GET /api/billing/subscriptions
|
|
7210
|
+
*
|
|
7211
|
+
* 订阅套餐额度(Kimi / Z.ai Coding / OpenCode / MiniMax / OpenRouter / Claude /
|
|
7212
|
+
* CommandCode / 腾讯云 TokenHub 等)。5 分钟缓存 + in-flight 去重(与 balance
|
|
7213
|
+
* 同模式——并发请求共享同一次进行中的刷新)。
|
|
7214
|
+
*
|
|
7215
|
+
* 路由文件只关心 HTTP 缓存逻辑:超时或未命中时触发 refreshOnce,失败时仍用
|
|
7216
|
+
* 旧缓存(不挂起已 writeHead 的响应)。上游查询(collectSubscriptions +
|
|
7217
|
+
* resolveSubscriptionKeys + 适配器 + 套餐知识库)在装配器里跑。
|
|
7218
|
+
*/
|
|
7219
|
+
function registerSubscriptionsRoute(ctx, deps) {
|
|
7220
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7221
|
+
kind: "exact",
|
|
7222
|
+
path: "/api/billing/subscriptions",
|
|
7223
|
+
handler: async (req, res) => {
|
|
7224
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7225
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7226
|
+
if (Date.now() - deps.cached().at >= deps.cacheMs) try {
|
|
7227
|
+
await deps.refreshOnce();
|
|
7228
|
+
} catch (error) {
|
|
7229
|
+
console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
|
|
7230
|
+
}
|
|
7231
|
+
res.end(JSON.stringify({ quotas: deps.cached().quotas }));
|
|
7232
|
+
}
|
|
7233
|
+
}), "usage-billing: subscriptions route");
|
|
7234
|
+
}
|
|
7235
|
+
//#endregion
|
|
7236
|
+
//#region lib/types/routes/relay-quotas.js
|
|
7237
|
+
/**
|
|
7238
|
+
* 路由:GET /api/billing/relay-quotas
|
|
7239
|
+
*
|
|
7240
|
+
* 中转站额度(New API / Sub2API 余额 + 滚动窗口)。5 分钟缓存 + in-flight
|
|
7241
|
+
* 去重。诊断(每条路由的 origin / kind)随响应下发,供「我的中转站为什么不显
|
|
7242
|
+
* 示」自查。
|
|
7243
|
+
*
|
|
7244
|
+
* 路由文件只关心 HTTP 缓存 + 诊断序列化。上游查询(queryRelayQuotas +
|
|
7245
|
+
* 路由筛选 + isOfficialBaseUrl 排除)在装配器里跑。
|
|
7246
|
+
*/
|
|
7247
|
+
function registerRelayQuotasRoute(ctx, deps) {
|
|
7248
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7249
|
+
kind: "exact",
|
|
7250
|
+
path: "/api/billing/relay-quotas",
|
|
7251
|
+
handler: async (req, res) => {
|
|
7252
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7253
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7254
|
+
if (Date.now() - deps.cached().at >= deps.cacheMs) try {
|
|
7255
|
+
await deps.refreshOnce();
|
|
7256
|
+
} catch (error) {
|
|
7257
|
+
console.warn("[usage-billing] refreshRelay failed; serving cached relay quotas:", error);
|
|
7258
|
+
}
|
|
7259
|
+
const diagnostics = deps.cached().quotas.map((row) => ({
|
|
7260
|
+
route: row.route,
|
|
7261
|
+
origin: row.origin,
|
|
7262
|
+
kind: row.kind
|
|
7263
|
+
}));
|
|
7264
|
+
res.end(JSON.stringify({
|
|
7265
|
+
quotas: deps.cached().quotas,
|
|
7266
|
+
diagnostics
|
|
7267
|
+
}));
|
|
7268
|
+
}
|
|
7269
|
+
}), "usage-billing: relay-quotas route");
|
|
7270
|
+
}
|
|
7271
|
+
//#endregion
|
|
7272
|
+
//#region lib/types/routes/usage-stats.js
|
|
7273
|
+
/**
|
|
7274
|
+
* 路由:GET /api/billing/usage-stats
|
|
7275
|
+
*
|
|
7276
|
+
* 实时用量聚合文档(byDay / byModel / bySession / bySite / byTurn / perf 等)。
|
|
7277
|
+
* stale-while-revalidate:聚合是全量扫描(重度用户上万条会话的冷启动折叠以
|
|
7278
|
+
* 分钟计),HTTP 请求绝不全程等它——只给 RESPONSE_BUDGET_MS 的等待预算,超
|
|
7279
|
+
* 预算立即回最近一次快照;后台折叠完成后快照随之刷新(挂在聚合 promise 上,
|
|
7280
|
+
* 与响应路径解耦)。宿主是单进程,慢响应期间事件循环被折叠占住,会话/模型
|
|
7281
|
+
* 列表 RPC 会一起饿死(用户反馈的启动卡顿)。
|
|
7282
|
+
*
|
|
7283
|
+
* 路由文件只关心 HTTP 拼装:
|
|
7284
|
+
* - decorate(注入 PACKAGE_VERSION / 预算 / 阈值)
|
|
7285
|
+
* - 聚合预算(Promise.race + setTimeout.unref)
|
|
7286
|
+
* - 响应体缓存(`statsBodyCache`:装饰 + 序列化结果按 doc 引用 + config 字段
|
|
7287
|
+
* 缓存,30s 轮询期间复用)
|
|
7288
|
+
* - 失败路径(聚合失败 → 读快照候选 → 仍无则 `{ error }`)
|
|
7289
|
+
*
|
|
7290
|
+
* 聚合器(`aggregator.aggregate()` / `sessionUsageOf()`)+ 快照读写 + 配置在
|
|
7291
|
+
* 装配器里跑,路由只通过 Deps 拿到回调。
|
|
7292
|
+
*/
|
|
7293
|
+
/** 聚合文档核心结构的 wire 校验(S-7 可观察性金丝雀)。
|
|
7294
|
+
*
|
|
7295
|
+
* 客户端 loadUsageStats 已有逐字段 typeof 守卫(渲染层兜底),本校验是
|
|
7296
|
+
* 服务端边界的漂移告警:聚合器字段改名/类型漂移时,30s 轮询的第一轮就在
|
|
7297
|
+
* 宿主日志留下 `[usage-billing]` 告警,而不是等用户面板渲染崩了报 issue。
|
|
7298
|
+
*
|
|
7299
|
+
* 语义是「告警不拦截」:校验失败仍照常下发(客户端自行兜底),绝不因
|
|
7300
|
+
* schema 漂移把仪表盘整个 500——与「degrade, never fabricate」同一姿态。
|
|
7301
|
+
*
|
|
7302
|
+
* 只校验 fresh 聚合路径(快照兜底路径不校验:旧快照本就可能缺新字段,
|
|
7303
|
+
* 校验它们只会产生永久的假阳性告警)。
|
|
7304
|
+
*
|
|
7305
|
+
* 实现说明:刻意手写 typeof 检查而不用 schemastery——schemastery 是 config
|
|
7306
|
+
* 库,object schema 对 null / 数组 / 标量输入走宽容强转(补默认值)而非抛错,
|
|
7307
|
+
* 无法表达「形状必须精确」的校验语义。手写检查只认精确类型,缺字段即漂移。
|
|
7308
|
+
* 导出供测试。 */
|
|
7309
|
+
const NUM = (v) => typeof v === "number" && Number.isFinite(v);
|
|
7310
|
+
/** 一个 ModelUsage 桶的核心数字字段(客户端渲染硬依赖)。
|
|
7311
|
+
* `withReasoning`:byModel / byDay / total 的桶含 reasoning;byDayModels 的
|
|
7312
|
+
* 三维格接口只有 6 个数字字段(无 reasoning),复用同一检查会产生假阳性。 */
|
|
7313
|
+
function modelUsageError(value, path, withReasoning = true) {
|
|
7314
|
+
if (value === null || typeof value !== "object") return `${path} 不是对象`;
|
|
7315
|
+
const keys = withReasoning ? [
|
|
7316
|
+
"calls",
|
|
7317
|
+
"input",
|
|
7318
|
+
"output",
|
|
7319
|
+
"cacheHit",
|
|
7320
|
+
"cacheMiss",
|
|
7321
|
+
"cost",
|
|
7322
|
+
"reasoning"
|
|
7323
|
+
] : [
|
|
7324
|
+
"calls",
|
|
7325
|
+
"input",
|
|
7326
|
+
"output",
|
|
7327
|
+
"cacheHit",
|
|
7328
|
+
"cacheMiss",
|
|
7329
|
+
"cost"
|
|
7330
|
+
];
|
|
7331
|
+
for (const key of keys) if (!NUM(value[key])) return `${path}.${key} 不是有限数字`;
|
|
7332
|
+
return null;
|
|
7333
|
+
}
|
|
7334
|
+
/** 校验一份聚合文档;返回漂移原因(null = 形状正常)。纯只读,不改 doc。 */
|
|
7335
|
+
function usageStatsShapeError(doc) {
|
|
7336
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return "文档不是对象";
|
|
7337
|
+
const d = doc;
|
|
7338
|
+
if (!NUM(d.version)) return "version 不是有限数字";
|
|
7339
|
+
if (!NUM(d.updatedAt)) return "updatedAt 不是有限数字";
|
|
7340
|
+
const total = modelUsageError(d.total, "total");
|
|
7341
|
+
if (total !== null) return total;
|
|
7342
|
+
for (const [field, cellCheck] of [
|
|
7343
|
+
["byModel", (cell, p) => modelUsageError(cell, p)],
|
|
7344
|
+
["byDay", (cell, p) => modelUsageError(cell, p)],
|
|
7345
|
+
["byDayModels", (cell, p) => {
|
|
7346
|
+
if (cell === null || typeof cell !== "object") return `${p} 不是对象`;
|
|
7347
|
+
for (const [modelKey, usage] of Object.entries(cell)) {
|
|
7348
|
+
const err = modelUsageError(usage, `${p}.${modelKey}`, false);
|
|
7349
|
+
if (err !== null) return err;
|
|
7350
|
+
}
|
|
7351
|
+
return null;
|
|
7352
|
+
}]
|
|
7353
|
+
]) {
|
|
7354
|
+
const bucket = d[field];
|
|
7355
|
+
if (bucket === void 0) return `${field} 缺失(聚合路径恒带;快照兜底才允许缺)`;
|
|
7356
|
+
if (bucket === null || typeof bucket !== "object") return `${field} 不是对象`;
|
|
7357
|
+
for (const [key, cell] of Object.entries(bucket)) {
|
|
7358
|
+
const err = cellCheck(cell, `${field}.${key}`);
|
|
7359
|
+
if (err !== null) return err;
|
|
7360
|
+
}
|
|
7361
|
+
}
|
|
7362
|
+
const bySession = d.bySession;
|
|
7363
|
+
if (!Array.isArray(bySession)) return "bySession 不是数组";
|
|
7364
|
+
for (const [index, row] of bySession.entries()) {
|
|
7365
|
+
if (row === null || typeof row !== "object") return `bySession[${index}] 不是对象`;
|
|
7366
|
+
const r = row;
|
|
7367
|
+
if (typeof r.id !== "string" || r.id === "") return `bySession[${index}].id 不是非空字符串`;
|
|
7368
|
+
if (!NUM(r.calls)) return `bySession[${index}].calls 不是有限数字`;
|
|
7369
|
+
if (!NUM(r.cost)) return `bySession[${index}].cost 不是有限数字`;
|
|
7370
|
+
if (!NUM(r.lastActive)) return `bySession[${index}].lastActive 不是有限数字`;
|
|
7371
|
+
}
|
|
7372
|
+
return null;
|
|
7373
|
+
}
|
|
7374
|
+
function registerUsageStatsRoute(ctx, deps) {
|
|
7375
|
+
let statsBodyCache;
|
|
7376
|
+
ctx.effect(() => ctx.webServer.register({
|
|
7377
|
+
kind: "exact",
|
|
7378
|
+
path: "/api/billing/usage-stats",
|
|
7379
|
+
handler: async (req, res) => {
|
|
7380
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
7381
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7382
|
+
const decorate = (doc) => ({
|
|
7383
|
+
...doc,
|
|
7384
|
+
pluginVersion: deps.packageVersion,
|
|
7385
|
+
...deps.config.monthlyBudget === void 0 ? {} : { budget: deps.config.monthlyBudget },
|
|
7386
|
+
...deps.config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: deps.config.lowBalanceThreshold }
|
|
7387
|
+
});
|
|
7388
|
+
const statsBodyOf = (doc) => {
|
|
7389
|
+
const budget = deps.config.monthlyBudget;
|
|
7390
|
+
const threshold = deps.config.lowBalanceThreshold;
|
|
7391
|
+
if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
|
|
7392
|
+
const shapeError = usageStatsShapeError(doc);
|
|
7393
|
+
if (shapeError !== null) console.warn("[usage-billing] usage-stats document shape drift (serving anyway):", shapeError);
|
|
7394
|
+
const body = JSON.stringify(decorate(doc));
|
|
7395
|
+
statsBodyCache = {
|
|
7396
|
+
doc,
|
|
7397
|
+
budget,
|
|
7398
|
+
threshold,
|
|
7399
|
+
body
|
|
7400
|
+
};
|
|
7401
|
+
return body;
|
|
7402
|
+
};
|
|
7403
|
+
const aggregating = deps.aggregator.aggregate();
|
|
7404
|
+
aggregating.then((stats) => {
|
|
7405
|
+
deps.persistSnapshot(decorate(stats));
|
|
7406
|
+
}, () => {});
|
|
7407
|
+
let fresh;
|
|
7408
|
+
try {
|
|
7409
|
+
fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
|
|
7410
|
+
setTimeout(() => {
|
|
7411
|
+
resolve(void 0);
|
|
7412
|
+
}, deps.responseBudgetMs).unref();
|
|
7413
|
+
})]);
|
|
7414
|
+
} catch (error) {
|
|
7415
|
+
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
7416
|
+
}
|
|
7417
|
+
if (fresh !== void 0) {
|
|
7418
|
+
res.end(statsBodyOf(fresh));
|
|
7419
|
+
return;
|
|
7420
|
+
}
|
|
7421
|
+
for (const candidate of deps.candidates) {
|
|
7422
|
+
const doc = await deps.readSnapshot(candidate);
|
|
7423
|
+
if (doc === null) continue;
|
|
7424
|
+
res.end(JSON.stringify(decorate(doc)));
|
|
7425
|
+
return;
|
|
7426
|
+
}
|
|
7427
|
+
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
|
7428
|
+
}
|
|
7429
|
+
}), "usage-billing: usage-stats route");
|
|
7430
|
+
}
|
|
7431
|
+
//#endregion
|
|
7432
|
+
//#region lib/types/client/usage-billing-settings.js
|
|
7433
|
+
/** 设置命名空间 id(小写 kebab-case)。 */
|
|
7434
|
+
const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
|
|
7435
|
+
/** 该命名空间下用户可编辑的字段名。 */
|
|
7436
|
+
const ENABLE_USAGE_STATS_TOOL_FIELD = "enableUsageStatsTool";
|
|
7437
|
+
//#endregion
|
|
7438
|
+
//#region lib/types/index.js
|
|
7439
|
+
/**
|
|
7440
|
+
* Usage billing surface plugin, node half.
|
|
7441
|
+
*
|
|
7442
|
+
* Serves `/api/billing/usage-stats`: real usage aggregated from every
|
|
7443
|
+
* persisted session log (see `aggregate.ts`) — the browser dashboard reads it
|
|
7444
|
+
* instead of showing an empty snapshot. Aggregation is stale-while-revalidate:
|
|
7445
|
+
* requests get a short wait budget and fall back to the freshest persisted
|
|
7446
|
+
* snapshot while a slow full fold keeps running in the background (a heavy
|
|
7447
|
+
* user's first fold takes minutes and must not starve the single-process
|
|
7448
|
+
* host's RPC). When `sessionPersistence` is unavailable (or aggregation
|
|
7449
|
+
* fails), the configured `statsPath` / `DSH_USAGE_STATS` / conventional JSON
|
|
7450
|
+
* file is served as a fallback, and a missing file answers `{ error }` so the
|
|
7451
|
+
* dashboard shows zeros, never fabricated samples.
|
|
7452
|
+
*/
|
|
7453
|
+
const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
7454
|
+
/** 校验并透过合法命名空间 id;非法值 fail-loud(与上游原行为一致)。 */
|
|
7455
|
+
function validateSettingsNamespace(value) {
|
|
7456
|
+
if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
|
|
7457
|
+
return value;
|
|
7458
|
+
}
|
|
7459
|
+
/** usage_stats 工具开关的设置命名空间 id(下端与 node 共用同一常量)。 */
|
|
7460
|
+
const usageBillingSettingsNs = validateSettingsNamespace(BILLING_SETTINGS_NAMESPACE);
|
|
7461
|
+
/** 该命名空间的 wire schema:`enableUsageStatsTool` 布尔,默认关闭(issue 诉求)。 */
|
|
7462
|
+
const UsageBillingSettingsSchema = z.object({ [ENABLE_USAGE_STATS_TOOL_FIELD]: z.boolean().default(false) });
|
|
7463
|
+
/** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
|
|
7464
|
+
/** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
|
|
7465
|
+
const PRICING_REFRESH_INTERVAL_MS = 216e5;
|
|
7466
|
+
/** 历史回放预热的延迟:等宿主启动高峰(插件加载 / 路由挂载)过去再全量折叠,
|
|
7467
|
+
* 避免抢启动期的 CPU;纯延迟不阻塞任何请求,面板提前打开也只会提前聚合。 */
|
|
7468
|
+
const WARMUP_DELAY_MS = 3e3;
|
|
7469
|
+
/** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
|
|
7470
|
+
const SUBSCRIPTION_CACHE_MS = 3e5;
|
|
7471
|
+
const BALANCE_CACHE_MS = 3e5;
|
|
7472
|
+
/** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
|
|
7473
|
+
const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
7474
|
+
/**
|
|
7475
|
+
* 本插件版本号:从包自身的 package.json 读取(单一来源),随 usage-stats
|
|
7476
|
+
* 下发,供「设置 → 插件信息卡」展示。发布版 lib/index.js 相对包根解析。
|
|
7477
|
+
*/
|
|
7478
|
+
const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
|
|
7479
|
+
/** 统计快照的落盘节流(毫秒):快照只服务聚合失败回退与重启首屏(展示级
|
|
7480
|
+
* 新鲜度即可),降到 5 分钟一次——原子写含上一版备份是两次全量 JSON 落盘,
|
|
7481
|
+
* 重度用户快照上百 KB,跟随前端轮询 30 秒写一次纯属写放大。 */
|
|
7482
|
+
const SNAPSHOT_INTERVAL_MS = 3e5;
|
|
7483
|
+
/** usage-stats 响应的等待预算(毫秒):聚合在此预算内完成就回实时数据,
|
|
7484
|
+
* 超预算立即回最近快照(stale-while-revalidate),后台折叠继续跑。
|
|
7485
|
+
* 轻量用户暖缓存下的聚合是毫秒级,预算几乎不会触发。 */
|
|
7486
|
+
const RESPONSE_BUDGET_MS = 1500;
|
|
7487
|
+
/** 鉴权失败告警冷却(毫秒):同一 provider 在窗口内只提示一次,避免 30 秒轮询刷屏。 */
|
|
7488
|
+
const AUTH_WARN_COOLDOWN_MS = 18e5;
|
|
7489
|
+
/**
|
|
7490
|
+
* 鉴权失败分类告警(P1-5):余额 / 订阅查询返回 unauthorized 时,按
|
|
7491
|
+
* `source:provider` 去重并冷却告警,提示检查 llm-pi-ai 里该 provider 的 apiKeyEnv。
|
|
7492
|
+
*/
|
|
7493
|
+
const authWarnedAt = /* @__PURE__ */ new Map();
|
|
7494
|
+
function warnAuthOnce(source, provider, displayName) {
|
|
7495
|
+
const key = `${source}:${provider}`;
|
|
7496
|
+
const now = Date.now();
|
|
7497
|
+
const last = authWarnedAt.get(key);
|
|
7498
|
+
if (last !== void 0 && now - last < AUTH_WARN_COOLDOWN_MS) return;
|
|
7499
|
+
authWarnedAt.set(key, now);
|
|
7500
|
+
console.warn(`[usage-billing] ${displayName}(${provider})鉴权失败:请检查 llm-pi-ai 设置中该 provider 的凭据环境变量是否正确/有效。`);
|
|
7501
|
+
}
|
|
7502
|
+
/** 锁等待上限:账本提交是毫秒级 read-render-commit,2s 已数倍于正常持锁时长。 */
|
|
7503
|
+
const LEDGER_LOCK_WAIT_MS = 2e3;
|
|
7504
|
+
/**
|
|
7505
|
+
* 锁年龄超过该值判为崩溃残留的孤儿锁。正常持锁是毫秒级文件提交,没有活跃
|
|
7506
|
+
* writer 会合法持有 60s;库层刻意不做年龄判定(它无法假设调用方的操作时长),
|
|
7507
|
+
* 本调用方知道自己操作的上界,可以给出有依据的清理阈值。
|
|
7508
|
+
*/
|
|
7509
|
+
const STALE_LEDGER_LOCK_MS = 6e4;
|
|
7510
|
+
/**
|
|
7511
|
+
* 锁等待超时后的孤儿锁自愈(issue #48):仅当锁文件年龄超过
|
|
7512
|
+
* STALE_LEDGER_LOCK_MS 才清理,避免误删慢 writer 的活跃锁。
|
|
7513
|
+
* @param ledgerPath - 账本文件路径(锁为其 `.lock` 兄弟文件)。
|
|
7514
|
+
* @returns 是否清理了孤儿锁;false = 锁已消失或太新鲜,调用方上抛原错误。
|
|
7515
|
+
*/
|
|
7516
|
+
async function reclaimStaleLedgerLock(ledgerPath) {
|
|
7517
|
+
let ageMs;
|
|
7518
|
+
try {
|
|
7519
|
+
ageMs = Date.now() - (await lstat(`${ledgerPath}.lock`)).mtimeMs;
|
|
7520
|
+
} catch {
|
|
7521
|
+
return false;
|
|
7522
|
+
}
|
|
7523
|
+
if (ageMs < STALE_LEDGER_LOCK_MS) return false;
|
|
7524
|
+
console.warn(`[usage-billing] 账本锁已存在 ${Math.round(ageMs / 1e3)}s(崩溃残留),清理后重试:${ledgerPath}.lock`);
|
|
7525
|
+
await rm(`${ledgerPath}.lock`, { force: true });
|
|
7526
|
+
return true;
|
|
7527
|
+
}
|
|
7528
|
+
/**
|
|
7529
|
+
* Create the atomic file-backed durable-ledger store. The previous complete file
|
|
7530
|
+
* is retained as `.bak`; a malformed/missing main file falls back to that backup.
|
|
7531
|
+
*/
|
|
7532
|
+
function createFileUsageLedgerStore(ledgerPath) {
|
|
7533
|
+
return {
|
|
7534
|
+
async load() {
|
|
7535
|
+
for (const path of [ledgerPath, `${ledgerPath}.bak`]) try {
|
|
7536
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
7537
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
7538
|
+
const candidate = parsed;
|
|
7539
|
+
if (candidate.version === 1 && Array.isArray(candidate.sessions)) return parsed;
|
|
7540
|
+
}
|
|
7541
|
+
} catch {
|
|
7542
|
+
swallowed("ledger-main-unreadable");
|
|
7543
|
+
}
|
|
7544
|
+
},
|
|
7545
|
+
async save(document) {
|
|
7546
|
+
await mkdir(dirname(ledgerPath), {
|
|
7547
|
+
recursive: true,
|
|
7548
|
+
mode: 448
|
|
7549
|
+
});
|
|
7550
|
+
const commit = async () => {
|
|
7551
|
+
let existing;
|
|
7552
|
+
let existingDoc;
|
|
7553
|
+
try {
|
|
7554
|
+
existing = await readFile(ledgerPath, "utf8");
|
|
7555
|
+
const parsed = JSON.parse(existing);
|
|
7556
|
+
if (parsed.version === 1 && Array.isArray(parsed.sessions)) existingDoc = parsed;
|
|
7557
|
+
} catch {
|
|
7558
|
+
existing = void 0;
|
|
7559
|
+
}
|
|
7560
|
+
if (existing !== void 0) await writeFileAtomic(`${ledgerPath}.bak`, existing, {
|
|
7561
|
+
mode: 384,
|
|
7562
|
+
dirMode: 448
|
|
7563
|
+
});
|
|
7564
|
+
const merged = existingDoc === void 0 ? document : mergeUsageLedgerDocuments(document, existingDoc);
|
|
7565
|
+
await writeFileAtomic(ledgerPath, JSON.stringify(merged), {
|
|
7566
|
+
mode: 384,
|
|
7567
|
+
dirMode: 448
|
|
7568
|
+
});
|
|
7569
|
+
};
|
|
7570
|
+
try {
|
|
7571
|
+
await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
|
|
7572
|
+
} catch (error) {
|
|
7573
|
+
if (!(error instanceof Error) || !error.message.includes("timed out waiting for the writer lock")) throw error;
|
|
7574
|
+
if (!await reclaimStaleLedgerLock(ledgerPath)) throw error;
|
|
7575
|
+
await withFileLock(ledgerPath, commit, { waitMs: LEDGER_LOCK_WAIT_MS });
|
|
7576
|
+
}
|
|
7577
|
+
}
|
|
7578
|
+
};
|
|
7579
|
+
}
|
|
7580
|
+
/** Required services: the web server, the persisted session log store, and user settings. */
|
|
7581
|
+
const inject = [
|
|
7077
7582
|
"webServer",
|
|
7078
7583
|
"sessionPersistence",
|
|
7079
7584
|
"credentials",
|
|
@@ -7279,7 +7784,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
|
|
|
7279
7784
|
try {
|
|
7280
7785
|
const resolved = await credentials.resolve(credentialRef(env));
|
|
7281
7786
|
if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
|
|
7282
|
-
} catch {
|
|
7787
|
+
} catch (error) {
|
|
7788
|
+
swallowed("credential-resolve-failed", error);
|
|
7789
|
+
}
|
|
7283
7790
|
}
|
|
7284
7791
|
if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiCnApiKey !== "") keys.zaiRegion = "bigmodel-cn";
|
|
7285
7792
|
if (keys.opencodeApiKey === "") keys.opencodeApiKey = await readOpenCodeToken();
|
|
@@ -7305,7 +7812,9 @@ async function resolveSubscriptionKeys(settings, credentials) {
|
|
|
7305
7812
|
async function readOpenCodeToken() {
|
|
7306
7813
|
try {
|
|
7307
7814
|
return parseOpenCodeAuthDocument(JSON.parse(await readFile(join(homedir(), ".local", "share", "opencode", "auth.json"), "utf8")));
|
|
7308
|
-
} catch {
|
|
7815
|
+
} catch {
|
|
7816
|
+
swallowed("opencode-auth-missing");
|
|
7817
|
+
}
|
|
7309
7818
|
return "";
|
|
7310
7819
|
}
|
|
7311
7820
|
/**
|
|
@@ -7397,7 +7906,9 @@ function apply(ctx, config = {}) {
|
|
|
7397
7906
|
const candidate = ref;
|
|
7398
7907
|
if (typeof candidate.date === "string" && typeof candidate.total === "number" && typeof candidate.granted === "number" && typeof candidate.topped === "number" && typeof candidate.currency === "string") reconcileRef = candidate;
|
|
7399
7908
|
}
|
|
7400
|
-
} catch {
|
|
7909
|
+
} catch {
|
|
7910
|
+
swallowed("reconcile-ref-missing");
|
|
7911
|
+
}
|
|
7401
7912
|
})();
|
|
7402
7913
|
const persistReconcileRef = () => {
|
|
7403
7914
|
if (reconcileRef === null) return;
|
|
@@ -7466,83 +7977,32 @@ function apply(ctx, config = {}) {
|
|
|
7466
7977
|
mode: 384,
|
|
7467
7978
|
dirMode: 448
|
|
7468
7979
|
});
|
|
7469
|
-
} catch {
|
|
7980
|
+
} catch {
|
|
7981
|
+
swallowed("snapshot-bak-missing");
|
|
7982
|
+
}
|
|
7470
7983
|
try {
|
|
7471
7984
|
await writeFileAtomic(snapshotPath, payload, {
|
|
7472
7985
|
mode: 384,
|
|
7473
7986
|
dirMode: 448
|
|
7474
7987
|
});
|
|
7475
|
-
} catch {
|
|
7988
|
+
} catch {
|
|
7989
|
+
swallowed("snapshot-write-failed");
|
|
7990
|
+
}
|
|
7476
7991
|
})();
|
|
7477
7992
|
};
|
|
7478
7993
|
/** 读一个快照候选并解析成对象:主文件优先;主文件损坏时回退上一版 `.bak`(P0-3 崩溃恢复)。 */
|
|
7479
7994
|
const readSnapshot = async (candidate) => {
|
|
7480
7995
|
for (const path of [candidate, `${candidate}.bak`]) try {
|
|
7481
7996
|
return JSON.parse(await readFile(path, "utf8"));
|
|
7482
|
-
} catch {
|
|
7997
|
+
} catch {
|
|
7998
|
+
swallowed("snapshot-candidate-unreadable");
|
|
7999
|
+
}
|
|
7483
8000
|
return null;
|
|
7484
8001
|
};
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
};
|
|
7490
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7491
|
-
kind: "exact",
|
|
7492
|
-
path: "/api/billing/notify-claim",
|
|
7493
|
-
handler: async (req, res) => {
|
|
7494
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7495
|
-
if (req.method !== "POST") {
|
|
7496
|
-
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7497
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7498
|
-
return;
|
|
7499
|
-
}
|
|
7500
|
-
if (!isLoopbackOrigin(req.headers.origin) || !(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7501
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7502
|
-
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7503
|
-
return;
|
|
7504
|
-
}
|
|
7505
|
-
try {
|
|
7506
|
-
let body = "";
|
|
7507
|
-
for await (const chunk of req) {
|
|
7508
|
-
body += String(chunk);
|
|
7509
|
-
if (body.length > 2048) {
|
|
7510
|
-
res.end(JSON.stringify({ error: "body too large" }));
|
|
7511
|
-
return;
|
|
7512
|
-
}
|
|
7513
|
-
}
|
|
7514
|
-
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
7515
|
-
if (typeof parsed.key !== "string" || parsed.key === "" || parsed.key.length > 200) {
|
|
7516
|
-
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7517
|
-
res.end(JSON.stringify({ error: "invalid key" }));
|
|
7518
|
-
return;
|
|
7519
|
-
}
|
|
7520
|
-
if (!notifyClaims.loaded) {
|
|
7521
|
-
notifyClaims.loaded = true;
|
|
7522
|
-
try {
|
|
7523
|
-
const stored = JSON.parse(await readFile(notifyClaimsPath, "utf8"));
|
|
7524
|
-
if (Array.isArray(stored.keys)) notifyClaims.keys = stored.keys.filter((k) => typeof k === "string");
|
|
7525
|
-
} catch {}
|
|
7526
|
-
}
|
|
7527
|
-
if (notifyClaims.keys.includes(parsed.key)) {
|
|
7528
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7529
|
-
res.end(JSON.stringify({ claimed: false }));
|
|
7530
|
-
return;
|
|
7531
|
-
}
|
|
7532
|
-
notifyClaims.keys.push(parsed.key);
|
|
7533
|
-
if (notifyClaims.keys.length > 400) notifyClaims.keys = notifyClaims.keys.slice(-200);
|
|
7534
|
-
writeFileAtomic(notifyClaimsPath, JSON.stringify({ keys: notifyClaims.keys }), {
|
|
7535
|
-
mode: 384,
|
|
7536
|
-
dirMode: 448
|
|
7537
|
-
}).catch(() => {});
|
|
7538
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7539
|
-
res.end(JSON.stringify({ claimed: true }));
|
|
7540
|
-
} catch {
|
|
7541
|
-
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
7542
|
-
res.end(JSON.stringify({ error: "invalid" }));
|
|
7543
|
-
}
|
|
7544
|
-
}
|
|
7545
|
-
}), "usage-billing: notify-claim route");
|
|
8002
|
+
registerNotifyClaimRoute(ctx, {
|
|
8003
|
+
trustedHosts,
|
|
8004
|
+
claimsPath: `${snapshotPath}.notify-claims.json`
|
|
8005
|
+
});
|
|
7546
8006
|
let usageToolDisposer;
|
|
7547
8007
|
let usageToolRegister;
|
|
7548
8008
|
const syncUsageTool = () => {
|
|
@@ -7743,40 +8203,11 @@ function apply(ctx, config = {}) {
|
|
|
7743
8203
|
clearInterval(timer);
|
|
7744
8204
|
};
|
|
7745
8205
|
}, "usage-billing: pricing refresh timer");
|
|
7746
|
-
ctx
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7752
|
-
res.end(pricingBody());
|
|
7753
|
-
}
|
|
7754
|
-
}), "usage-billing: pricing route");
|
|
7755
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7756
|
-
kind: "exact",
|
|
7757
|
-
path: "/api/billing/pricing/refresh",
|
|
7758
|
-
handler: async (req, res) => {
|
|
7759
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7760
|
-
if (req.method !== "POST") {
|
|
7761
|
-
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7762
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7763
|
-
return;
|
|
7764
|
-
}
|
|
7765
|
-
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7766
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7767
|
-
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7768
|
-
return;
|
|
7769
|
-
}
|
|
7770
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7771
|
-
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7772
|
-
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7773
|
-
return;
|
|
7774
|
-
}
|
|
7775
|
-
await refreshPricing();
|
|
7776
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7777
|
-
res.end(pricingBody());
|
|
7778
|
-
}
|
|
7779
|
-
}), "usage-billing: pricing refresh route");
|
|
8206
|
+
registerPricingRoutes(ctx, {
|
|
8207
|
+
trustedHosts,
|
|
8208
|
+
body: pricingBody,
|
|
8209
|
+
refresh: refreshPricing
|
|
8210
|
+
});
|
|
7780
8211
|
let balanceCache = {
|
|
7781
8212
|
at: 0,
|
|
7782
8213
|
doc: { balances: [] }
|
|
@@ -7807,7 +8238,9 @@ function apply(ctx, config = {}) {
|
|
|
7807
8238
|
let todayOfficialCost = 0;
|
|
7808
8239
|
try {
|
|
7809
8240
|
todayOfficialCost = (await aggregator.aggregate()).byDay[today]?.officialCost ?? 0;
|
|
7810
|
-
} catch {
|
|
8241
|
+
} catch {
|
|
8242
|
+
swallowed("reconcile-aggregate-skipped");
|
|
8243
|
+
}
|
|
7811
8244
|
const result = reconcileBalanceDelta(reconcileRef, official, todayOfficialCost, today, now);
|
|
7812
8245
|
const refChanged = result.ref !== null && result.ref !== reconcileRef;
|
|
7813
8246
|
reconcileRef = result.ref;
|
|
@@ -7837,80 +8270,22 @@ function apply(ctx, config = {}) {
|
|
|
7837
8270
|
});
|
|
7838
8271
|
return balanceInflight;
|
|
7839
8272
|
};
|
|
7840
|
-
ctx
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
}
|
|
7857
|
-
}), "usage-billing: balance route");
|
|
7858
|
-
ctx.effect(() => ctx.webServer.register({
|
|
7859
|
-
kind: "exact",
|
|
7860
|
-
path: "/api/billing/usage-tool",
|
|
7861
|
-
handler: async (req, res) => {
|
|
7862
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
7863
|
-
if (req.method !== "GET" && req.method !== "POST") {
|
|
7864
|
-
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
7865
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
7866
|
-
return;
|
|
7867
|
-
}
|
|
7868
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
7869
|
-
const enabled = usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config);
|
|
7870
|
-
if (req.method === "GET") {
|
|
7871
|
-
res.end(JSON.stringify({ enabled }));
|
|
7872
|
-
return;
|
|
7873
|
-
}
|
|
7874
|
-
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
7875
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
7876
|
-
res.end(JSON.stringify({ error: "forbidden: loopback only" }));
|
|
7877
|
-
return;
|
|
7878
|
-
}
|
|
7879
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
7880
|
-
res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
|
|
7881
|
-
res.end(JSON.stringify({ error: "unsupported content-type" }));
|
|
7882
|
-
return;
|
|
7883
|
-
}
|
|
7884
|
-
try {
|
|
7885
|
-
let body = "";
|
|
7886
|
-
for await (const chunk of req) {
|
|
7887
|
-
body += String(chunk);
|
|
7888
|
-
if (body.length > 512) {
|
|
7889
|
-
res.end(JSON.stringify({ error: "body too large" }));
|
|
7890
|
-
return;
|
|
7891
|
-
}
|
|
7892
|
-
}
|
|
7893
|
-
const next = JSON.parse(body === "" ? "{}" : body).enabled === true;
|
|
7894
|
-
if (usageSettingsScope !== void 0) {
|
|
7895
|
-
await usageSettingsScope.update({ enableUsageStatsTool: next });
|
|
7896
|
-
syncUsageTool();
|
|
7897
|
-
} else if (liveSettings !== void 0) {
|
|
7898
|
-
await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
|
|
7899
|
-
syncUsageTool();
|
|
7900
|
-
} else {
|
|
7901
|
-
res.end(JSON.stringify({ error: "settings unavailable" }));
|
|
7902
|
-
return;
|
|
7903
|
-
}
|
|
7904
|
-
res.end(JSON.stringify({
|
|
7905
|
-
ok: true,
|
|
7906
|
-
enabled: next
|
|
7907
|
-
}));
|
|
7908
|
-
} catch (error) {
|
|
7909
|
-
console.warn("[usage-billing] usage-tool toggle write-back failed:", error);
|
|
7910
|
-
res.end(JSON.stringify({ error: "invalid" }));
|
|
7911
|
-
}
|
|
7912
|
-
}
|
|
7913
|
-
}), "usage-billing: usage-tool route");
|
|
8273
|
+
registerBalanceRoute(ctx, {
|
|
8274
|
+
trustedHosts,
|
|
8275
|
+
cached: () => balanceCache,
|
|
8276
|
+
refreshOnce: refreshBalanceOnce,
|
|
8277
|
+
cacheMs: BALANCE_CACHE_MS
|
|
8278
|
+
});
|
|
8279
|
+
registerUsageToolRoute(ctx, {
|
|
8280
|
+
trustedHosts,
|
|
8281
|
+
read: () => usageSettingsScope !== void 0 ? usageSettingsScope.get().enableUsageStatsTool : readUsageStatsToggle(config),
|
|
8282
|
+
write: async (next) => {
|
|
8283
|
+
if (usageSettingsScope !== void 0) await usageSettingsScope.update({ enableUsageStatsTool: next });
|
|
8284
|
+
else if (liveSettings !== void 0) await liveSettings.update(liveSettings.ns, { [ENABLE_USAGE_STATS_TOOL_FIELD]: next });
|
|
8285
|
+
else throw new Error("settings unavailable");
|
|
8286
|
+
},
|
|
8287
|
+
syncTool: syncUsageTool
|
|
8288
|
+
});
|
|
7914
8289
|
let quotaCache = {
|
|
7915
8290
|
at: 0,
|
|
7916
8291
|
quotas: []
|
|
@@ -7954,20 +8329,12 @@ function apply(ctx, config = {}) {
|
|
|
7954
8329
|
});
|
|
7955
8330
|
return quotasInflight;
|
|
7956
8331
|
};
|
|
7957
|
-
ctx
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) try {
|
|
7964
|
-
await refreshQuotasOnce();
|
|
7965
|
-
} catch (error) {
|
|
7966
|
-
console.warn("[usage-billing] refreshQuotas failed; serving cached quotas:", error);
|
|
7967
|
-
}
|
|
7968
|
-
res.end(JSON.stringify({ quotas: quotaCache.quotas }));
|
|
7969
|
-
}
|
|
7970
|
-
}), "usage-billing: subscriptions route");
|
|
8332
|
+
registerSubscriptionsRoute(ctx, {
|
|
8333
|
+
trustedHosts,
|
|
8334
|
+
cached: () => quotaCache,
|
|
8335
|
+
refreshOnce: refreshQuotasOnce,
|
|
8336
|
+
cacheMs: SUBSCRIPTION_CACHE_MS
|
|
8337
|
+
});
|
|
7971
8338
|
let relayCache = {
|
|
7972
8339
|
at: 0,
|
|
7973
8340
|
quotas: []
|
|
@@ -7997,81 +8364,22 @@ function apply(ctx, config = {}) {
|
|
|
7997
8364
|
});
|
|
7998
8365
|
return relayInflight;
|
|
7999
8366
|
};
|
|
8000
|
-
ctx
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
res.end(JSON.stringify({
|
|
8017
|
-
quotas: relayCache.quotas,
|
|
8018
|
-
diagnostics
|
|
8019
|
-
}));
|
|
8020
|
-
}
|
|
8021
|
-
}), "usage-billing: relay-quotas route");
|
|
8022
|
-
let statsBodyCache;
|
|
8023
|
-
ctx.effect(() => ctx.webServer.register({
|
|
8024
|
-
kind: "exact",
|
|
8025
|
-
path: "/api/billing/usage-stats",
|
|
8026
|
-
handler: async (req, res) => {
|
|
8027
|
-
if (!guardLoopback(req, res, trustedHosts)) return;
|
|
8028
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
8029
|
-
const decorate = (doc) => ({
|
|
8030
|
-
...doc,
|
|
8031
|
-
pluginVersion: PACKAGE_VERSION,
|
|
8032
|
-
...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
|
|
8033
|
-
...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
|
|
8034
|
-
});
|
|
8035
|
-
const statsBodyOf = (doc) => {
|
|
8036
|
-
const budget = config.monthlyBudget;
|
|
8037
|
-
const threshold = config.lowBalanceThreshold;
|
|
8038
|
-
if (statsBodyCache !== void 0 && statsBodyCache.doc === doc && statsBodyCache.budget === budget && statsBodyCache.threshold === threshold) return statsBodyCache.body;
|
|
8039
|
-
const body = JSON.stringify(decorate(doc));
|
|
8040
|
-
statsBodyCache = {
|
|
8041
|
-
doc,
|
|
8042
|
-
budget,
|
|
8043
|
-
threshold,
|
|
8044
|
-
body
|
|
8045
|
-
};
|
|
8046
|
-
return body;
|
|
8047
|
-
};
|
|
8048
|
-
const aggregating = aggregator.aggregate();
|
|
8049
|
-
aggregating.then((stats) => {
|
|
8050
|
-
persistSnapshot(decorate(stats));
|
|
8051
|
-
}, () => {});
|
|
8052
|
-
let fresh;
|
|
8053
|
-
try {
|
|
8054
|
-
fresh = await Promise.race([aggregating.then((stats) => stats), new Promise((resolve) => {
|
|
8055
|
-
setTimeout(() => {
|
|
8056
|
-
resolve(void 0);
|
|
8057
|
-
}, RESPONSE_BUDGET_MS).unref();
|
|
8058
|
-
})]);
|
|
8059
|
-
} catch (error) {
|
|
8060
|
-
console.error("[usage-billing] usage-stats aggregate failed, falling back to snapshot:", error);
|
|
8061
|
-
}
|
|
8062
|
-
if (fresh !== void 0) {
|
|
8063
|
-
res.end(statsBodyOf(fresh));
|
|
8064
|
-
return;
|
|
8065
|
-
}
|
|
8066
|
-
for (const candidate of candidates) {
|
|
8067
|
-
const doc = await readSnapshot(candidate);
|
|
8068
|
-
if (doc === null) continue;
|
|
8069
|
-
res.end(JSON.stringify(decorate(doc)));
|
|
8070
|
-
return;
|
|
8071
|
-
}
|
|
8072
|
-
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
|
8073
|
-
}
|
|
8074
|
-
}), "usage-billing: usage-stats route");
|
|
8367
|
+
registerRelayQuotasRoute(ctx, {
|
|
8368
|
+
trustedHosts,
|
|
8369
|
+
cached: () => relayCache,
|
|
8370
|
+
refreshOnce: refreshRelayOnce,
|
|
8371
|
+
cacheMs: SUBSCRIPTION_CACHE_MS
|
|
8372
|
+
});
|
|
8373
|
+
registerUsageStatsRoute(ctx, {
|
|
8374
|
+
trustedHosts,
|
|
8375
|
+
aggregator,
|
|
8376
|
+
config,
|
|
8377
|
+
candidates,
|
|
8378
|
+
persistSnapshot,
|
|
8379
|
+
readSnapshot,
|
|
8380
|
+
packageVersion: PACKAGE_VERSION,
|
|
8381
|
+
responseBudgetMs: RESPONSE_BUDGET_MS
|
|
8382
|
+
});
|
|
8075
8383
|
}
|
|
8076
8384
|
//#endregion
|
|
8077
|
-
export {
|
|
8385
|
+
export { adaptSessionPersistence, apply, createFileUsageLedgerStore, guardLoopback, hostNameOf, inject, isLoopbackHost, isLoopbackOrigin, isLoopbackPeer, normalizeTrustedHosts, readPiAiProviderRoutes, readRegisteredProviderIds, resolveSubscriptionKeys };
|