dsh-bottom-info-bar 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +41 -10
- package/lib/index.js +312 -57
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -25,6 +25,12 @@ const HIDE_SPEED_FIELDS = true;
|
|
|
25
25
|
// RPC 超时兜底:host 侧 15s 超时之上再留余量;端点挂起时 20s 内必失败,杜绝永久"加载中…"
|
|
26
26
|
const RPC_TIMEOUT_MS = 20000;
|
|
27
27
|
|
|
28
|
+
// 首启强制刷新窗口:页面刚打开/用户手动刷新后的这几秒内,快照请求带 force=true,
|
|
29
|
+
// host 会绕过缓存与失败退避当场重查服务商——用户刷新页面立即看到最新余额/额度,
|
|
30
|
+
// 不必干等后台 60s 自动周期。覆盖窗口需容纳会话模型从空到确定的短暂翻转期。
|
|
31
|
+
const BOOT_AT = Date.now();
|
|
32
|
+
const FORCE_REFRESH_WINDOW_MS = 6000;
|
|
33
|
+
|
|
28
34
|
// rpc(method, args, externalSignal):
|
|
29
35
|
// - 超时:20s 未响应 → abort 并以"请求超时"失败(fetch 挂起不阻塞界面)
|
|
30
36
|
// - 可中止:传入外部 AbortSignal(组件卸载时 abort)→ 立即取消并拒绝"请求已取消"
|
|
@@ -343,11 +349,14 @@ module.exports = {
|
|
|
343
349
|
const requestVersion = ++loadVersionRef.current;
|
|
344
350
|
const activeSelection = selection || activeSessionModel;
|
|
345
351
|
const selectionArgs = activeSelection ? { selection: { provider: activeSelection.provider, model: activeSelection.model } } : {};
|
|
352
|
+
// 首启窗口内(打开/刷新网页头几秒)→ 快照类请求强制重查;之后的 30s 周期轮询走常规缓存节奏
|
|
353
|
+
const force = Date.now() - BOOT_AT < FORCE_REFRESH_WINDOW_MS;
|
|
354
|
+
if (force) { selectionArgs.force = true; }
|
|
346
355
|
const signal = abortRef.current ? abortRef.current.signal : null;
|
|
347
356
|
// 逐接口容错:allSettled 等全部 settle(最坏 20s 超时兜底),任一失败只降级该端点,
|
|
348
357
|
// 不拖垮其他成功数据;合并逻辑在 mergeLoadResults(失败端点保留旧值 + 记录错误)
|
|
349
358
|
Promise.allSettled([
|
|
350
|
-
rpc('getBalanceSnapshot', activeSelection ? { provider: activeSelection.provider } : null, signal),
|
|
359
|
+
rpc('getBalanceSnapshot', activeSelection ? { provider: activeSelection.provider, force: force } : (force ? { force: true } : null), signal),
|
|
351
360
|
rpc('getPricing', selectionArgs, signal),
|
|
352
361
|
rpc('getUsageSummary', Object.assign({ sessionId: sessionId }, selectionArgs), signal),
|
|
353
362
|
rpc('getBillingMode', selectionArgs, signal),
|
|
@@ -656,33 +665,39 @@ module.exports = {
|
|
|
656
665
|
metric('距' + (peakNow ? '空闲' : '高峰'), fmtCountdown(pr.nextSwitch.at - now))));
|
|
657
666
|
}
|
|
658
667
|
|
|
659
|
-
//
|
|
668
|
+
// 本会话花费(公共小部件 pushSessionCost:只显示钱;hover 显示 今天/近一月/全部)
|
|
660
669
|
// 始终显示:新会话/对话刚开始尚无记账时显示 ¥0.000,hover 仍可查看持久化的 今天/近一月/全部
|
|
670
|
+
pushSessionCost(groups, trailingErrorGroups, !!(bal && bal.currency === 'USD'));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 本会话花费块(余额制 与 订阅·充值余额 形态共用的小部件):
|
|
674
|
+
// 只显示钱;hover 浮窗显示 含子代理说明 + 今天 / 近一月 / 全部;金额数字加粗。
|
|
675
|
+
// usdSymbol:账户币种为美元时,hover 汇总行用 $ 前缀(本会话单值仍按其真实计价币种)
|
|
676
|
+
function pushSessionCost(groups, trailingErrorGroups, usdSymbol) {
|
|
661
677
|
const usg = state.usage;
|
|
678
|
+
const errs = state.errors || {};
|
|
662
679
|
if (usg) {
|
|
663
680
|
const cs = usg.currentSession;
|
|
664
681
|
const costCNY = cs && cs.costs && cs.costs.CNY != null ? cs.costs.CNY : null;
|
|
665
682
|
const costUSD = cs && cs.costs && cs.costs.USD != null ? cs.costs.USD : null;
|
|
666
|
-
const
|
|
683
|
+
const symbol = usdSymbol ? '$' : '¥';
|
|
667
684
|
const costTxt = costCNY != null ? '¥' + costCNY.toFixed(3)
|
|
668
|
-
: (costUSD != null ? '$' + costUSD.toFixed(3) :
|
|
669
|
-
const symbol = bal && bal.currency === 'USD' ? '$' : '¥';
|
|
685
|
+
: (costUSD != null ? '$' + costUSD.toFixed(3) : symbol + (0).toFixed(3));
|
|
670
686
|
const today = usg.todaySpend != null ? '今天 ' + symbol + fmt(usg.todaySpend, 3) : '';
|
|
671
687
|
const month = usg.monthSpend != null ? '近一月 ' + symbol + fmt(usg.monthSpend, 3) : '';
|
|
672
688
|
const total = usg.totalSpend != null ? '全部 ' + symbol + fmt(usg.totalSpend, 3) : '';
|
|
673
689
|
const detail = [today, month, total].filter(function (s) { return s.length > 0; }).join(' · ');
|
|
674
|
-
// v1.7 发布前微调:hover 明确标注"含子代理"——本会话聚合同账户所有记录(同起点后)
|
|
675
690
|
groups.push(React.createElement('span', { key: 'convo',
|
|
676
691
|
title: '本会话 ' + costTxt + '(含子代理)' + (detail ? '\n' + detail : '') },
|
|
677
692
|
metric('本会话', costTxt)));
|
|
678
|
-
} else if (
|
|
679
|
-
// 本次 RPC 失败且无旧数据:只降级花费块,其余端点数据照常渲染
|
|
693
|
+
} else if (errs.usage) {
|
|
680
694
|
trailingErrorGroups.push(React.createElement('span', { className: 'bi-err', key: 'usageerr', title: '花费暂不可用;不会影响对话。' }, '花费获取失败'));
|
|
681
695
|
}
|
|
682
696
|
}
|
|
683
697
|
|
|
684
|
-
// ----
|
|
685
|
-
//
|
|
698
|
+
// ---- 订阅制模式(互斥替换余额制版):
|
|
699
|
+
// 套餐额度型:订阅服务+模型 → 三窗口额度 → 距重置倒计时(余额/时段/花费/token 不显示)
|
|
700
|
+
// 充值余额型(如智谱按量账户,windows 空且有 sub.balance):服务商+模型 → 余额 → 本会话花费 ----
|
|
686
701
|
function subscriptionFailureHint(error, source) {
|
|
687
702
|
const kind = error && error.kind;
|
|
688
703
|
const serviceName = subscriptionServiceName(source);
|
|
@@ -726,6 +741,22 @@ module.exports = {
|
|
|
726
741
|
trailingErrorGroups.push(React.createElement('span', { className: 'bi-stale', key: 'substale', title: subscriptionFailureHint(sub.error, sub.source || (visibleBillingMode && visibleBillingMode.provider)) }, '刷新失败'));
|
|
727
742
|
return;
|
|
728
743
|
}
|
|
744
|
+
// v1.8:充值余额模式——无额度窗口但有 balance 字段(如智谱普通 API 余额用户)
|
|
745
|
+
// 显示"余额 ¥XX.XX",与 Coding Plan 额度窗口互斥
|
|
746
|
+
if (!hasData && typeof sub.balance === 'number' && isFinite(sub.balance)) {
|
|
747
|
+
const balTxt = '¥' + fmt(sub.balance, 2);
|
|
748
|
+
const titleLines = ['订阅源:' + subscriptionServiceName(visibleBillingMode && visibleBillingMode.provider) + '(' + (sub.plan || '充值余额') + ')',
|
|
749
|
+
'可用余额:' + balTxt];
|
|
750
|
+
groups.push(React.createElement('span', { key: 'subbal', title: titleLines.join('\n') },
|
|
751
|
+
metric('余额', balTxt)));
|
|
752
|
+
// 充值余额用户按量付费,花销与余额同等重要 → 追加公共花费块(含子代理聚合)
|
|
753
|
+
pushSessionCost(groups, trailingErrorGroups, false);
|
|
754
|
+
// host 快照失败(sub.error)或本次 RPC 失败(errors.sub)→ 保留旧数据 + 降级标记
|
|
755
|
+
if (sub.error || errors.sub) {
|
|
756
|
+
trailingErrorGroups.push(React.createElement('span', { className: 'bi-stale', key: 'substale', title: subscriptionFailureHint(sub.error || { kind: 'exception', message: String(errors.sub || '') }, sub.source || (visibleBillingMode && visibleBillingMode.provider)) }, '刷新失败'));
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
729
760
|
// 窗口缺失(如 Codex 无 5 小时窗口)→ 跳过窗口组,不占位、不报错
|
|
730
761
|
if (hasData) {
|
|
731
762
|
// 简洁模式下选择"时间最短且有重置时刻"的窗口(刷新最快,用户最需关注):
|
package/lib/index.js
CHANGED
|
@@ -107,6 +107,11 @@ function accountForProvider(pid) {
|
|
|
107
107
|
return null
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// 服务商在账单(usage)里直接报告真实金额时,按服务商固定币种回填。
|
|
111
|
+
// 仅列明确用美元计价的聚合商(如 OpenRouter);未登记的走价目表币种回退。
|
|
112
|
+
// 动机:聚合商路由模型数量庞大、价目无法静态维护——官方报出的钱 > 本地任何换算。
|
|
113
|
+
const PROVIDER_REPORTED_CURRENCY = { openrouter: 'USD' }
|
|
114
|
+
|
|
110
115
|
// ---------- 双模式纯逻辑(模式检测 / 窗口映射 / 响应解析;单测直接提取) ----------
|
|
111
116
|
|
|
112
117
|
// 窗口时长(秒)→ 窗口键:18000≈5小时 / 604800≈7天 / 2592000≈30天,5% 容差;未知返回 null
|
|
@@ -765,13 +770,135 @@ export default {
|
|
|
765
770
|
'deepseek-chat': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.5, inputCacheMiss: 2.0, output: 8.0 } },
|
|
766
771
|
'gpt-4o': { currency: 'USD', mode: 'flat', price: { inputCacheHit: 1.25, inputCacheMiss: 2.5, output: 10.0 } },
|
|
767
772
|
'gpt-4o-mini': { currency: 'USD', mode: 'flat', price: { inputCacheHit: 0.15, inputCacheMiss: 0.15, output: 0.6 } },
|
|
773
|
+
// ---------- 智谱 BigModel 官方按量价(CNY/百万 tokens;来源 open.bigmodel.cn/pricing,
|
|
774
|
+
// 采集记录与全档分段备注见 docs/research/bigmodel-pricing-202608.md)----------
|
|
775
|
+
// 分段模型主条目取基础档(输入≤32K 等),长上下文档暂不计入——影响与限制见体系审计报告。
|
|
776
|
+
'glm-5.3': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 2, inputCacheMiss: 8, output: 28 } },
|
|
777
|
+
// flash 存在"5折限时两周"双价:内置表取刊例价 0.8/0.23/2.8(保守);
|
|
778
|
+
// 远程目录按用户当前实扣配促销价(活动窗口约至 2026-09 初,详见 catalog/pricing.json notes)
|
|
779
|
+
'glm-5.3-flash': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.23, inputCacheMiss: 0.8, output: 2.8 } },
|
|
780
|
+
'glm-5.2': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 2, inputCacheMiss: 8, output: 28 } },
|
|
781
|
+
'glm-5.1': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 1.3, inputCacheMiss: 6, output: 24 } },
|
|
782
|
+
'glm-5-turbo': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 1.2, inputCacheMiss: 5, output: 22 } },
|
|
783
|
+
'glm-5v-turbo': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 1.2, inputCacheMiss: 5, output: 22 } },
|
|
784
|
+
'glm-4.7': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.4, inputCacheMiss: 2, output: 8 } },
|
|
785
|
+
'glm-4.5-air': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.16, inputCacheMiss: 0.8, output: 2 } },
|
|
786
|
+
// ---------- 小米 MiMo 按量官方价(mimo.mi.com/docs/zh-CN/price/pay-as-you-go,2026-08-06 调价后 CNY;
|
|
787
|
+
// 本机 pi-ai 目录里的 cost 字段是 USD 数字——严禁混用,详见体系审计报告)----------
|
|
788
|
+
'mimo-v2-flash': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.02, inputCacheMiss: 1, output: 2 } },
|
|
789
|
+
'mimo-v2-omni': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.02, inputCacheMiss: 1, output: 2 } },
|
|
790
|
+
'mimo-v2-pro': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.025, inputCacheMiss: 3, output: 6 } },
|
|
791
|
+
'mimo-v2.5': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.02, inputCacheMiss: 1, output: 2 } },
|
|
792
|
+
'mimo-v2.5-pro': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.025, inputCacheMiss: 3, output: 6 } },
|
|
793
|
+
// mimo-v2.5-pro-ultraspeed:官方仅美元口径、人民币数未独立确认 → 按"不臆测"暂不收录
|
|
794
|
+
// ---------- 阶跃 StepFun 在售文本模型(platform.stepfun.com 定价详情;step-1/2/3 系已于 2026-07 下线)----------
|
|
795
|
+
'step-3.7-flash': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.27, inputCacheMiss: 1.35, output: 8.1 } },
|
|
796
|
+
'step-3.5-flash': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.14, inputCacheMiss: 0.7, output: 2.1 } },
|
|
797
|
+
// ---------- Kimi 国内站(provider=moonshotai-cn → api.moonshot.cn 计费 ¥)----------
|
|
798
|
+
// 同型号在 api.moonshot.ai 国际站按 $ 另计:官方 USD 数字待采集后以 'moonshotai:kimi-*'
|
|
799
|
+
// 作用域键收录;裸 kimi-* 键刻意留空,防止币种串档。
|
|
800
|
+
'moonshotai-cn:kimi-k3': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 2, inputCacheMiss: 20, output: 100 } },
|
|
801
|
+
'moonshotai-cn:kimi-k2.5': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.7, inputCacheMiss: 4, output: 21 } },
|
|
802
|
+
'moonshotai-cn:kimi-k2.6': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 1.1, inputCacheMiss: 6.5, output: 27 } },
|
|
803
|
+
'moonshotai-cn:kimi-k2.7-code': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 1.3, inputCacheMiss: 6.5, output: 27 } },
|
|
804
|
+
'moonshotai-cn:kimi-k2.7-code-highspeed':{ currency: 'CNY', mode: 'flat', price: { inputCacheHit: 2.6, inputCacheMiss: 13, output: 54 } },
|
|
805
|
+
'moonshotai-cn:kimi-k2-0711-preview': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.15, inputCacheMiss: 0.6, output: 2.5 } },
|
|
806
|
+
'moonshotai-cn:kimi-k2-0905-preview': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.15, inputCacheMiss: 0.6, output: 2.5 } },
|
|
807
|
+
'moonshotai-cn:kimi-k2-thinking': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.15, inputCacheMiss: 0.6, output: 2.5 } },
|
|
808
|
+
'moonshotai-cn:kimi-k2-thinking-turbo': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.15, inputCacheMiss: 1.15, output: 8 } },
|
|
809
|
+
'moonshotai-cn:kimi-k2-turbo-preview': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.6, inputCacheMiss: 2.4, output: 10 } },
|
|
768
810
|
};
|
|
769
|
-
function modelCurrency(model) {
|
|
770
|
-
|
|
811
|
+
function modelCurrency(provider, model) {
|
|
812
|
+
// 服务商作用域键优先:同名模型跨计费域时(Kimi 国内外),币种必须跟作用域走
|
|
813
|
+
const entry = pricingEntryFor(provider, model);
|
|
771
814
|
if (entry && entry.currency) return entry.currency;
|
|
772
815
|
return model && model.indexOf('gpt') === 0 ? 'USD' : 'CNY';
|
|
773
816
|
}
|
|
774
817
|
const DEFAULT_MODEL = 'deepseek-v4-flash';
|
|
818
|
+
|
|
819
|
+
// ---------- 远程价目目录(v1.8):内置表兜底 + 启动/定时增量更新 ----------
|
|
820
|
+
// 设计目标(用户铁律):接入新模型/新价格不再依赖插件发版。
|
|
821
|
+
// 机制:启动时先用磁盘缓存离线合并,再异步拉取远程 catalog/pricing.json 增量合并;
|
|
822
|
+
// 每合并一次立即重跑 unpriced 回填 → 新覆盖的模型的历史账目自动补算。
|
|
823
|
+
// 安全边界:仅接受"声明式数字"(统一价 + 白名单币种),绝不执行远程代码;
|
|
824
|
+
// 匿名 GET 不携带任何密钥/用户标识;失败静默降级缓存与内置表,不影响信息栏。
|
|
825
|
+
const REMOTE_PRICING_URL = process.env.DSH_BOTTOM_INFO_BAR_PRICING_URL
|
|
826
|
+
|| 'https://raw.githubusercontent.com/songoao25/dsh-bottom-info-bar/main/catalog/pricing.json';
|
|
827
|
+
const REMOTE_PRICING_REFRESH_MS = 6 * 60 * 60 * 1000; // 定时刷新:6 小时
|
|
828
|
+
const PRICING_CACHE_FILE = join(DATA_DIR, 'pricing-cache.json');
|
|
829
|
+
let remotePricingEtag = null;
|
|
830
|
+
|
|
831
|
+
// 校验目录条目:只放行可安全参与计算的声明式数据
|
|
832
|
+
function sanitizeRemotePricingEntries(raw) {
|
|
833
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
834
|
+
const entries = {};
|
|
835
|
+
for (const key of Object.keys(raw)) {
|
|
836
|
+
if (Object.keys(entries).length >= 512) break; // 容量上限,防滥用
|
|
837
|
+
if (!/^[A-Za-z0-9._:-]{1,64}$/.test(key)) continue; // 允许 "provider:model" 作用域键(跨币种同名模型)
|
|
838
|
+
const e = raw[key];
|
|
839
|
+
if (!e || typeof e !== 'object') continue;
|
|
840
|
+
// v1 仅接受统一价(flat);分时价的窗口规则须随框架参数化后再开放远程下发
|
|
841
|
+
if (e.mode !== 'flat' || !e.price || typeof e.price !== 'object') continue;
|
|
842
|
+
if (e.currency !== 'CNY' && e.currency !== 'USD') continue; // 白名单币种
|
|
843
|
+
const hit = Number(e.price.inputCacheHit);
|
|
844
|
+
const miss = Number(e.price.inputCacheMiss);
|
|
845
|
+
const out = Number(e.price.output);
|
|
846
|
+
if (!Number.isFinite(hit) || !Number.isFinite(miss) || !Number.isFinite(out)) continue;
|
|
847
|
+
if (miss <= 0 || hit < 0 || out < 0) continue;
|
|
848
|
+
if (hit > miss) continue; // 缓存命中不可能比未命中更贵:脏数据拒收
|
|
849
|
+
entries[key] = { currency: e.currency, mode: 'flat', price: { inputCacheHit: hit, inputCacheMiss: miss, output: out } };
|
|
850
|
+
}
|
|
851
|
+
return Object.keys(entries).length > 0 ? entries : null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// 合并进运行时价目表并触发回填;返回本次新增/更新的条数
|
|
855
|
+
function applyRemotePricingEntries(entries) {
|
|
856
|
+
if (!entries) return 0;
|
|
857
|
+
let n = 0;
|
|
858
|
+
for (const key of Object.keys(entries)) {
|
|
859
|
+
PRICING[key] = entries[key];
|
|
860
|
+
n++;
|
|
861
|
+
}
|
|
862
|
+
if (n > 0) backfillUnpricedRecords();
|
|
863
|
+
return n;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function loadPricingCacheFromDisk() {
|
|
867
|
+
try {
|
|
868
|
+
const parsed = JSON.parse(readFileSync(PRICING_CACHE_FILE, 'utf8'));
|
|
869
|
+
return sanitizeRemotePricingEntries(parsed && parsed.entries);
|
|
870
|
+
} catch (err) { /* 无缓存/损坏 → 内置表兜底 */ }
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function savePricingCacheToDisk(entries) {
|
|
875
|
+
try {
|
|
876
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
877
|
+
writeAndSync(PRICING_CACHE_FILE, JSON.stringify({ fetchedAt: Date.now(), etag: remotePricingEtag, entries: entries }), 'w');
|
|
878
|
+
} catch (err) { /* 缓存写失败不致命 */ }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function refreshRemotePricing(reason) {
|
|
882
|
+
try {
|
|
883
|
+
const headers = {};
|
|
884
|
+
if (remotePricingEtag) headers['If-None-Match'] = remotePricingEtag;
|
|
885
|
+
const res = await fetch(REMOTE_PRICING_URL, { headers, signal: AbortSignal.timeout(10000) });
|
|
886
|
+
if (res.status === 304) return { status: 'fresh' }; // 目录未变
|
|
887
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
888
|
+
const body = await res.json();
|
|
889
|
+
const entries = sanitizeRemotePricingEntries(body);
|
|
890
|
+
if (!entries) throw new Error('目录格式不符合规范');
|
|
891
|
+
remotePricingEtag = res.headers.get('etag');
|
|
892
|
+
savePricingCacheToDisk(entries);
|
|
893
|
+
const merged = applyRemotePricingEntries(entries);
|
|
894
|
+
console.warn('[dsh-bottom-info-bar] 远程价目已合并(' + reason + '):' + merged + ' 条有效条目');
|
|
895
|
+
return { status: 'merged', count: merged };
|
|
896
|
+
} catch (err) {
|
|
897
|
+
console.warn('[dsh-bottom-info-bar] 远程价目获取失败(' + reason + '),沿用缓存/内置表:' + String((err && err.message) || err));
|
|
898
|
+
return { status: 'error', message: String((err && err.message) || err) };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
775
902
|
const SCENARIOS = [
|
|
776
903
|
{ id: 'qa', label: '日常问答', outputK: 2, inputK: 4 },
|
|
777
904
|
{ id: 'coding', label: '中等编码任务', outputK: 15, inputK: 30 },
|
|
@@ -911,7 +1038,7 @@ export default {
|
|
|
911
1038
|
|
|
912
1039
|
function refreshProviderBalance(pid) {
|
|
913
1040
|
const prov = PROVIDERS[pid];
|
|
914
|
-
if (!prov) return;
|
|
1041
|
+
if (!prov) return Promise.resolve();
|
|
915
1042
|
const seq = (balanceSeq[pid] || 0) + 1;
|
|
916
1043
|
balanceSeq[pid] = seq;
|
|
917
1044
|
if (!prov.balanceAPI) {
|
|
@@ -919,9 +1046,10 @@ export default {
|
|
|
919
1046
|
const spend = providerSpend(pid);
|
|
920
1047
|
const total = Math.max(0, prov.initialTopUp - spend);
|
|
921
1048
|
balances[pid] = { data: { currency: 'USD', total: total, granted: 0, toppedUp: prov.initialTopUp }, fetchedAt: Date.now(), error: null };
|
|
922
|
-
return;
|
|
1049
|
+
return Promise.resolve();
|
|
923
1050
|
}
|
|
924
|
-
|
|
1051
|
+
// 返回本次刷新 Promise:强制刷新路径(客户端打开页面)需等待最新结果落快照后再返回
|
|
1052
|
+
return (async function () {
|
|
925
1053
|
let cred = null;
|
|
926
1054
|
try {
|
|
927
1055
|
cred = await ctx.credentials.resolve(prov.credential);
|
|
@@ -967,6 +1095,7 @@ export default {
|
|
|
967
1095
|
const subscriptionSeq = {}; // 每 source 刷新序号:仅最新一次请求可写入快照
|
|
968
1096
|
const subscriptionInFlight = {}; // { [sourceKey]: Promise } 并发去重(同一时刻只发一个请求)
|
|
969
1097
|
const subscriptionRequested = {}; // 仅"客户端请求过"的源进入 60s 周期刷新(余额制下不打扰订阅接口)
|
|
1098
|
+
const subscriptionSourceProvider = {}; // { [sourceKey]: providerId }——记住请求该源时的 provider(zai 系按 provider 路由 host/凭据)
|
|
970
1099
|
const subscriptionLastFailAt = {}; // { [sourceKey]: ms } 上次订阅刷新失败时刻(失败退避:期内不重试)
|
|
971
1100
|
|
|
972
1101
|
// FR-8 / D7:Codex / ChatGPT 订阅卡(纯本地通道)
|
|
@@ -1038,12 +1167,17 @@ export default {
|
|
|
1038
1167
|
// host:zai-coding-cn → https://open.bigmodel.cn;zai → https://api.z.ai
|
|
1039
1168
|
// 认证:两者均 Authorization 裸 API Key,绝无 Bearer 前缀(加 Bearer 会 401)
|
|
1040
1169
|
async function resolveZaiKey(providerId) {
|
|
1170
|
+
// 凭据与 host 匹配:zai(国际)→ 优先 ZAI_API_KEY;zai-coding-cn(国内)→ 优先 ZAI_CODING_CN_API_KEY。
|
|
1171
|
+
// 用户通常只配一个,回退另一名防止误配。
|
|
1172
|
+
const isCn = providerId === 'zai-coding-cn';
|
|
1173
|
+
const primary = isCn ? 'ZAI_CODING_CN_API_KEY' : 'ZAI_API_KEY';
|
|
1174
|
+
const fallback = isCn ? 'ZAI_API_KEY' : 'ZAI_CODING_CN_API_KEY';
|
|
1041
1175
|
try {
|
|
1042
|
-
const cred = await ctx.credentials.resolve(
|
|
1176
|
+
const cred = await ctx.credentials.resolve(primary);
|
|
1043
1177
|
if (cred && typeof cred.value === 'string' && cred.value.length > 0) return cred.value;
|
|
1044
1178
|
} catch (err) { /* 回退 */ }
|
|
1045
1179
|
try {
|
|
1046
|
-
const cred = await ctx.credentials.resolve(
|
|
1180
|
+
const cred = await ctx.credentials.resolve(fallback);
|
|
1047
1181
|
if (cred && typeof cred.value === 'string' && cred.value.length > 0) return cred.value;
|
|
1048
1182
|
} catch (err) { /* 未配置 */ }
|
|
1049
1183
|
return null;
|
|
@@ -1096,36 +1230,82 @@ export default {
|
|
|
1096
1230
|
return { plan: planName, windows: windows };
|
|
1097
1231
|
}
|
|
1098
1232
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1233
|
+
// 解析智谱充值余额响应(/api/biz/account/query-customer-account-report)
|
|
1234
|
+
// 被 CodexBar PR#3109、CodexMeter PR#2 等多个项目验证的非公开控制台 API
|
|
1235
|
+
function parseZaiBalance(body) {
|
|
1236
|
+
if (!body || typeof body !== 'object') return null;
|
|
1237
|
+
if (body.success === false || body.code !== 200) return null;
|
|
1238
|
+
const d = body.data;
|
|
1239
|
+
if (!d || typeof d !== 'object') return null;
|
|
1240
|
+
// availableBalance 优先(可用余额),fallback 到 balance
|
|
1241
|
+
const bal = typeof d.availableBalance === 'number' ? d.availableBalance
|
|
1242
|
+
: (typeof d.balance === 'number' ? d.balance : null);
|
|
1243
|
+
if (bal === null || !isFinite(bal)) return null;
|
|
1244
|
+
return { balance: bal };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function fetchZaiUsage(providerId) {
|
|
1248
|
+
// 按 provider 路由 host 与凭据:zai-coding-cn → open.bigmodel.cn(国内密钥);
|
|
1249
|
+
// zai → api.z.ai(国际密钥)。两端均裸 API Key(无 Bearer 前缀)。
|
|
1250
|
+
// 自动检测用户类型:先尝试 Coding Plan 额度接口,失败后回退充值余额接口。
|
|
1251
|
+
const resolvedProvider = providerId === 'zai-coding-cn' ? 'zai-coding-cn' : 'zai';
|
|
1252
|
+
const key = await resolveZaiKey(resolvedProvider);
|
|
1104
1253
|
if (!key) {
|
|
1105
|
-
return { error: { kind: 'no-key', message: '未配置智谱 API Key(
|
|
1254
|
+
return { error: { kind: 'no-key', message: '未配置智谱 API Key(ZAI_API_KEY 或 ZAI_CODING_CN_API_KEY)' } };
|
|
1106
1255
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1256
|
+
const host = zaiHostForProvider(resolvedProvider);
|
|
1257
|
+
|
|
1258
|
+
// ---- 第一步:尝试 Coding Plan 额度接口 ----
|
|
1259
|
+
try {
|
|
1260
|
+
const res = await fetch(host + '/api/monitor/usage/quota/limit', {
|
|
1261
|
+
headers: { Authorization: key }, // 裸 API Key,无 Bearer 前缀
|
|
1262
|
+
signal: AbortSignal.timeout(15000),
|
|
1263
|
+
});
|
|
1264
|
+
const body = await res.json().catch(() => null);
|
|
1265
|
+
// 智谱 API 常在 HTTP 200 内返回业务错误({code:401, success:false, msg:...}),需先检查
|
|
1266
|
+
if (body && body.success === false) {
|
|
1267
|
+
const msg = body.msg || body.message || '';
|
|
1268
|
+
// "当前用户不存在 coding plan" → 非订阅用户,回退到充值余额接口
|
|
1269
|
+
if (/不存在.*coding\s*plan|no.*coding\s*plan/i.test(msg)) {
|
|
1270
|
+
return fetchZaiBalanceFallback(host, key, resolvedProvider);
|
|
1118
1271
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1272
|
+
if (body.code === 401 || /过期|不正确|unauthorized|expired/i.test(msg))
|
|
1273
|
+
return { error: { kind: 'auth', message: '智谱 API 认证失败(密钥过期或不正确)' } };
|
|
1274
|
+
return { error: { kind: 'http', message: '请求失败(' + (body.code || '') + ' ' + msg + ')' } };
|
|
1275
|
+
}
|
|
1276
|
+
if (!res.ok) return { error: { kind: 'http', message: '请求失败(HTTP ' + res.status + ')' } };
|
|
1277
|
+
const parsed = parseZaiQuota(body);
|
|
1278
|
+
if (!parsed) return { error: { kind: 'parse', message: '响应格式异常' } };
|
|
1279
|
+
return { data: { provider: 'zai', plan: parsed.plan, windows: parsed.windows } };
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
return { error: { kind: 'exception', message: String((err && err.message) || err) } };
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// 智谱充值余额回退:Coding Plan 接口报"不存在 coding plan"时自动查询充值余额
|
|
1286
|
+
async function fetchZaiBalanceFallback(host, key, resolvedProvider) {
|
|
1287
|
+
try {
|
|
1288
|
+
// open.bigmodel.cn 与 www.bigmodel.cn 均可用;国际站 api.z.ai 无此接口
|
|
1289
|
+
const balanceHost = host === 'https://api.z.ai' ? 'https://open.bigmodel.cn' : host;
|
|
1290
|
+
const res = await fetch(balanceHost + '/api/biz/account/query-customer-account-report', {
|
|
1291
|
+
headers: { Authorization: 'Bearer ' + key }, // Bearer 认证(与裸 Key 均可)
|
|
1292
|
+
signal: AbortSignal.timeout(15000),
|
|
1293
|
+
});
|
|
1294
|
+
const body = await res.json().catch(() => null);
|
|
1295
|
+
if (body && body.success === false) {
|
|
1296
|
+
const msg = body.msg || body.message || '';
|
|
1297
|
+
if (body.code === 401 || body.code === 1000 || /过期|不正确|unauthorized|expired/i.test(msg))
|
|
1298
|
+
return { error: { kind: 'auth', message: '智谱 API 认证失败(密钥过期或不正确)' } };
|
|
1299
|
+
return { error: { kind: 'http', message: '请求失败(' + (body.code || '') + ' ' + msg + ')' } };
|
|
1126
1300
|
}
|
|
1301
|
+
if (!res.ok) return { error: { kind: 'http', message: '请求失败(HTTP ' + res.status + ')' } };
|
|
1302
|
+
const parsed = parseZaiBalance(body);
|
|
1303
|
+
if (!parsed) return { error: { kind: 'parse', message: '响应格式异常' } };
|
|
1304
|
+
// 返回充值余额:balance 字段携带金额,windows 为空(无额度窗口)
|
|
1305
|
+
return { data: { provider: 'zai', plan: '充值余额', windows: [], balance: parsed.balance } };
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
return { error: { kind: 'exception', message: String((err && err.message) || err) } };
|
|
1127
1308
|
}
|
|
1128
|
-
return { error: { kind: 'exception', message: '所有 host 均失败' } };
|
|
1129
1309
|
}
|
|
1130
1310
|
|
|
1131
1311
|
// v1.7 通用凭据读取:缺失/读取失败一律 null(错误信息不含密钥)
|
|
@@ -1361,13 +1541,14 @@ export default {
|
|
|
1361
1541
|
};
|
|
1362
1542
|
|
|
1363
1543
|
// 触发一次刷新(并发去重 + seq 防旧覆盖);返回本次刷新 Promise
|
|
1364
|
-
|
|
1544
|
+
// providerId:需要按 provider 路由 host/凭据的源(如 zai 系)使用;其余源忽略
|
|
1545
|
+
function kickSubscriptionRefresh(sourceKey, providerId) {
|
|
1365
1546
|
const src = SUBSCRIPTION_SOURCES[sourceKey];
|
|
1366
1547
|
if (!src) return Promise.resolve();
|
|
1367
1548
|
if (subscriptionInFlight[sourceKey]) return subscriptionInFlight[sourceKey];
|
|
1368
1549
|
const seq = (subscriptionSeq[sourceKey] || 0) + 1;
|
|
1369
1550
|
subscriptionSeq[sourceKey] = seq;
|
|
1370
|
-
subscriptionInFlight[sourceKey] = src.fetch().then(function (result) {
|
|
1551
|
+
subscriptionInFlight[sourceKey] = src.fetch(providerId).then(function (result) {
|
|
1371
1552
|
if (subscriptionSeq[sourceKey] === seq) {
|
|
1372
1553
|
subscriptions[sourceKey] = mergeSubscriptionResult(subscriptions[sourceKey], result);
|
|
1373
1554
|
// 失败退避记录:失败记时刻(期内不重试),成功清零
|
|
@@ -1394,7 +1575,7 @@ export default {
|
|
|
1394
1575
|
if (!subscriptionRequested[sourceKey]) continue;
|
|
1395
1576
|
const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
|
|
1396
1577
|
if (nowMs - lastFailAt < SUBSCRIPTION_RETRY_BACKOFF_MS) continue;
|
|
1397
|
-
kickSubscriptionRefresh(sourceKey);
|
|
1578
|
+
kickSubscriptionRefresh(sourceKey, subscriptionSourceProvider[sourceKey]);
|
|
1398
1579
|
}
|
|
1399
1580
|
}
|
|
1400
1581
|
|
|
@@ -1455,15 +1636,16 @@ export default {
|
|
|
1455
1636
|
}
|
|
1456
1637
|
|
|
1457
1638
|
// RPC:当前订阅额度快照 + 模式判定(非订阅模式直接返回,不发任何订阅请求)
|
|
1458
|
-
async function getSubscriptionSnapshotRpc(selection) {
|
|
1639
|
+
async function getSubscriptionSnapshotRpc(selection, force) {
|
|
1459
1640
|
const sel = selection || modelSelection();
|
|
1460
1641
|
const bm = detectBillingMode(sel.provider, config.billingMode);
|
|
1461
|
-
const out = { mode: bm.mode, provider: sel.provider, reason: bm.reason, source: null, plan: null, planType: null, expiryAt: null, windows: [], fetchedAt: null, error: null };
|
|
1642
|
+
const out = { mode: bm.mode, provider: sel.provider, reason: bm.reason, source: null, plan: null, planType: null, expiryAt: null, windows: [], balance: null, fetchedAt: null, error: null };
|
|
1462
1643
|
if (bm.mode !== 'subscription') return out;
|
|
1463
1644
|
const sourceKey = subscriptionSourceFor(sel.provider);
|
|
1464
1645
|
if (!sourceKey) return out;
|
|
1465
1646
|
out.source = sourceKey;
|
|
1466
1647
|
subscriptionRequested[sourceKey] = true; // 该源进入 60s 周期刷新
|
|
1648
|
+
subscriptionSourceProvider[sourceKey] = sel.provider; // 记住 provider(zai 系路由需要)
|
|
1467
1649
|
const snap = subscriptions[sourceKey] || { data: null, fetchedAt: null, error: null };
|
|
1468
1650
|
const nowMs = Date.now();
|
|
1469
1651
|
const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
|
|
@@ -1471,11 +1653,13 @@ export default {
|
|
|
1471
1653
|
// 减少对未公开 wham 接口的请求,也避免"刷新失败"提示随每次轮询反复闪烁(失败期内直接读缓存快照)
|
|
1472
1654
|
const stale = (!snap.fetchedAt || (nowMs - snap.fetchedAt) > SUBSCRIPTION_REFRESH_MS)
|
|
1473
1655
|
&& (nowMs - lastFailAt) >= SUBSCRIPTION_RETRY_BACKOFF_MS;
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1656
|
+
// force(客户端打开/刷新网页后的首启窗口)绕过新鲜度与失败退避,当场重查,
|
|
1657
|
+
// 让用户手动刷新页面时立刻拿到最新额度/余额,而不是干等下一轮自动刷新
|
|
1658
|
+
if (force || stale) {
|
|
1659
|
+
const inflight = kickSubscriptionRefresh(sourceKey, subscriptionSourceProvider[sourceKey]);
|
|
1660
|
+
// 从未成功过(无旧数据)或强制刷新 → 等本次结果返回最新数据(含错误);
|
|
1661
|
+
// 已有旧数据的普通刷新 → 后台刷新,本次直接返回快照(不阻塞轮询)
|
|
1662
|
+
if (!snap.data || force) await inflight;
|
|
1479
1663
|
}
|
|
1480
1664
|
const cur = subscriptions[sourceKey] || { data: null, fetchedAt: null, error: null };
|
|
1481
1665
|
if (cur.data) {
|
|
@@ -1483,6 +1667,7 @@ export default {
|
|
|
1483
1667
|
out.windows = cur.data.windows;
|
|
1484
1668
|
out.planType = cur.data.planType;
|
|
1485
1669
|
out.expiryAt = cur.data.expiryAt;
|
|
1670
|
+
out.balance = typeof cur.data.balance === 'number' ? cur.data.balance : null;
|
|
1486
1671
|
}
|
|
1487
1672
|
out.fetchedAt = cur.fetchedAt;
|
|
1488
1673
|
out.error = cur.error;
|
|
@@ -1497,7 +1682,7 @@ export default {
|
|
|
1497
1682
|
cloudflare: { fetch: fetchCloudflareBilling }, // cloudflare-ai-gateway / cloudflare-workers-ai 共用
|
|
1498
1683
|
};
|
|
1499
1684
|
|
|
1500
|
-
async function getBillingSnapshotRpc(selection) {
|
|
1685
|
+
async function getBillingSnapshotRpc(selection, force) {
|
|
1501
1686
|
const sel = selection || modelSelection();
|
|
1502
1687
|
const bm = detectBillingMode(sel.provider, config.billingMode);
|
|
1503
1688
|
const out = { mode: bm.mode, provider: sel.provider, reason: bm.reason, type: null, data: null, fetchedAt: null, error: null, now: Date.now() };
|
|
@@ -1511,9 +1696,10 @@ export default {
|
|
|
1511
1696
|
const lastFailAt = billingLastFailAt[key] || 0;
|
|
1512
1697
|
const stale = (!snap.fetchedAt || (nowMs - snap.fetchedAt) > BILLING_REFRESH_MS)
|
|
1513
1698
|
&& (nowMs - lastFailAt) >= BILLING_RETRY_BACKOFF_MS;
|
|
1514
|
-
|
|
1699
|
+
// force(客户端打开/刷新网页后的首启窗口)绕过新鲜度与失败退避,当场重查最新账单
|
|
1700
|
+
if (force || stale) {
|
|
1515
1701
|
const inflight = kickBillingRefresh(key);
|
|
1516
|
-
if (!snap.data) await inflight;
|
|
1702
|
+
if (!snap.data || force) await inflight;
|
|
1517
1703
|
}
|
|
1518
1704
|
const cur = billingSnapshots[key] || { data: null, fetchedAt: null, error: null };
|
|
1519
1705
|
out.data = cur.data;
|
|
@@ -1705,9 +1891,18 @@ export default {
|
|
|
1705
1891
|
}
|
|
1706
1892
|
|
|
1707
1893
|
// ---------- 定价计算 ----------
|
|
1894
|
+
// 价目键解析:优先"服务商:模型"作用域键(解决同名模型跨计费域价格/币种不同的问题,
|
|
1895
|
+
// 如 Kimi 同一型号在 api.moonshot.cn=¥ 与 api.moonshot.ai=$ 两套价);
|
|
1896
|
+
// 无作用域键时回退裸模型键。远程目录可下发任意一种形态。
|
|
1897
|
+
function pricingEntryFor(provider, model) {
|
|
1898
|
+
const scoped = PRICING[provider + ':' + model];
|
|
1899
|
+
if (scoped && typeof scoped === 'object') return scoped;
|
|
1900
|
+
return PRICING[model] || null;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1708
1903
|
function computePricing(nowMs, selection) {
|
|
1709
1904
|
const sel = selection || modelSelection();
|
|
1710
|
-
const entry =
|
|
1905
|
+
const entry = pricingEntryFor(sel.provider, sel.model);
|
|
1711
1906
|
const period = entry && entry.mode === 'peak-valley' ? currentPeriod(nowMs) : 'flat';
|
|
1712
1907
|
let prices = null;
|
|
1713
1908
|
if (entry) {
|
|
@@ -1782,6 +1977,35 @@ export default {
|
|
|
1782
1977
|
let dirty = loadedUsageRecords.migratedLegacyRecord;
|
|
1783
1978
|
let ledgerError = null;
|
|
1784
1979
|
|
|
1980
|
+
// ---------- 一次性回填:unpriced 历史记录按当前价目表补算 ----------
|
|
1981
|
+
// 背景:价目表随适配逐步收录;收录之前落账的记录 pricingStatus='unpriced'(cost=null),
|
|
1982
|
+
// 导致本会话/今日/累计花费长期漏算(例如接入智谱后其历史对话金额恒为 0)。
|
|
1983
|
+
// 规则:① 只补"从未有过价格"的记录,绝不改写已 priced 的历史金额;
|
|
1984
|
+
// ② 仅限带稳定 id 的记录(无 id 的远古记录在快照+流水双源下无法安全幂等);
|
|
1985
|
+
// ③ 补算结果一次性写入快照,此后与正常记录同权参与全部汇总。
|
|
1986
|
+
function backfillUnpricedRecords() {
|
|
1987
|
+
let count = 0;
|
|
1988
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
1989
|
+
const rec = usageRecords[i];
|
|
1990
|
+
if (!rec || rec.pricingStatus === 'priced') continue;
|
|
1991
|
+
if (!(typeof rec.id === 'string' && rec.id.length > 0)) continue;
|
|
1992
|
+
if (Number.isFinite(rec.cost) && rec.cost >= 0) continue;
|
|
1993
|
+
const billed = costOf(rec, false);
|
|
1994
|
+
if (billed == null) continue; // 价目表仍无该模型 → 维持 unpriced
|
|
1995
|
+
rec.cost = billed;
|
|
1996
|
+
rec.currency = modelCurrency(rec.provider, rec.model);
|
|
1997
|
+
rec.pricingStatus = 'priced';
|
|
1998
|
+
rec.pricingVersion = 'builtin-backfill-' + packageVersion();
|
|
1999
|
+
count++;
|
|
2000
|
+
}
|
|
2001
|
+
if (count > 0) {
|
|
2002
|
+
dirty = true;
|
|
2003
|
+
scheduleSave();
|
|
2004
|
+
console.warn('[dsh-bottom-info-bar] 价目表回填:' + count + ' 条 unpriced 记录已按官方单价补算');
|
|
2005
|
+
}
|
|
2006
|
+
return count;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1785
2009
|
function writeAndSync(filePath, content, flags) {
|
|
1786
2010
|
let fd = null;
|
|
1787
2011
|
try {
|
|
@@ -1855,6 +2079,8 @@ export default {
|
|
|
1855
2079
|
});
|
|
1856
2080
|
}
|
|
1857
2081
|
|
|
2082
|
+
const unpricedWarnedModels = new Set(); // 每个模型只提醒一次,避免高频 warn 刷日志
|
|
2083
|
+
|
|
1858
2084
|
function recordUsage(options, usage, status) {
|
|
1859
2085
|
const u = usage || {};
|
|
1860
2086
|
// 数值清洗:uncachedInputTokens 的 != null 对 NaN 恒真、|| 0 挡不住 Infinity/负值——
|
|
@@ -1875,14 +2101,28 @@ export default {
|
|
|
1875
2101
|
// Freeze the actual price at usage time. Historical totals must not
|
|
1876
2102
|
// change merely because the plugin's reference price table is updated.
|
|
1877
2103
|
const billed = costOf(rec, false);
|
|
1878
|
-
|
|
1879
|
-
|
|
2104
|
+
// ① 聚合商在 usage 中直接给出的真实金额(如 OpenRouter 的 usage.cost)——
|
|
2105
|
+
// 官方报出的钱优先级最高,聚合商场景从此免维护静态价目表;
|
|
2106
|
+
// ② 价目表换算(当前各家官方单价);③ 都没有 → unpriced 待启动回填。
|
|
2107
|
+
if (billed == null && typeof u.cost === 'number' && Number.isFinite(u.cost) && u.cost >= 0) {
|
|
2108
|
+
rec.cost = u.cost;
|
|
2109
|
+
rec.currency = PROVIDER_REPORTED_CURRENCY[rec.provider] || modelCurrency(rec.provider, rec.model);
|
|
2110
|
+
rec.pricingStatus = 'priced';
|
|
2111
|
+
rec.pricingVersion = 'provider-reported-' + packageVersion();
|
|
2112
|
+
} else if (billed != null) {
|
|
2113
|
+
rec.currency = modelCurrency(rec.provider, rec.model);
|
|
1880
2114
|
rec.cost = billed;
|
|
1881
2115
|
rec.pricingStatus = 'priced';
|
|
1882
2116
|
rec.pricingVersion = 'builtin-' + packageVersion();
|
|
1883
2117
|
} else {
|
|
1884
2118
|
rec.pricingStatus = 'unpriced';
|
|
1885
2119
|
rec.pricingVersion = null;
|
|
2120
|
+
// 诊断:理论上本插件任一实例的 PRICING 均应同步;若此处为 false 说明存在多副本或键名不匹配。
|
|
2121
|
+
// 每个模型仅首次出现时提醒一次,防止未收录模型(如订阅目录下的混合路由)高频刷日志
|
|
2122
|
+
if (!unpricedWarnedModels.has(rec.model)) {
|
|
2123
|
+
unpricedWarnedModels.add(rec.model);
|
|
2124
|
+
console.warn('[dsh-bottom-info-bar] 记账未计价(每模型仅提示一次):model="' + rec.model + '" 价目表含该模型=' + Boolean(PRICING[rec.model] || PRICING[rec.provider + ':' + rec.model]));
|
|
2125
|
+
}
|
|
1886
2126
|
}
|
|
1887
2127
|
const writeError = appendUsageJournal(rec);
|
|
1888
2128
|
if (writeError) {
|
|
@@ -1945,7 +2185,7 @@ export default {
|
|
|
1945
2185
|
// v2 ledger records carry the charge as observed. Use it for normal
|
|
1946
2186
|
// reporting; only the "all-offpeak" forecast intentionally recalculates.
|
|
1947
2187
|
if (!forceOffpeak && Number.isFinite(record.cost) && record.cost >= 0) return record.cost;
|
|
1948
|
-
const entry =
|
|
2188
|
+
const entry = pricingEntryFor(record.provider, record.model);
|
|
1949
2189
|
if (!entry) return null;
|
|
1950
2190
|
let p;
|
|
1951
2191
|
if (entry.mode === 'peak-valley') {
|
|
@@ -2063,13 +2303,13 @@ export default {
|
|
|
2063
2303
|
const key = balanceProviderKey(sel.provider || config.activeProvider);
|
|
2064
2304
|
const snap = balances[key];
|
|
2065
2305
|
if (snap && snap.data && snap.data.currency) return snap.data.currency;
|
|
2066
|
-
return modelCurrency(sel.model);
|
|
2306
|
+
return modelCurrency(sel.provider, sel.model);
|
|
2067
2307
|
}
|
|
2068
2308
|
|
|
2069
2309
|
function recordCurrency(record) {
|
|
2070
2310
|
return record && typeof record.currency === 'string' && record.currency.length > 0
|
|
2071
2311
|
? record.currency
|
|
2072
|
-
: modelCurrency(record && record.model);
|
|
2312
|
+
: modelCurrency(record && record.provider, record && record.model);
|
|
2073
2313
|
}
|
|
2074
2314
|
|
|
2075
2315
|
function spendSummary(nowMs, selection) {
|
|
@@ -2203,8 +2443,9 @@ export default {
|
|
|
2203
2443
|
let scenarios = [];
|
|
2204
2444
|
if (balance != null && pricing.prices) {
|
|
2205
2445
|
const p = pricing.prices;
|
|
2206
|
-
const
|
|
2207
|
-
const
|
|
2446
|
+
const pvEntry = pricing.mode === 'peak-valley' ? pricingEntryFor(pricing.provider, pricing.model) : null;
|
|
2447
|
+
const peakPrices = pvEntry ? pvEntry.peak : p;
|
|
2448
|
+
const offpeakPrices = pvEntry ? pvEntry.offpeak : p;
|
|
2208
2449
|
scenarios = SCENARIOS.map(function (sc) {
|
|
2209
2450
|
return {
|
|
2210
2451
|
id: sc.id, label: sc.label, outputK: sc.outputK, inputK: sc.inputK,
|
|
@@ -2349,8 +2590,14 @@ export default {
|
|
|
2349
2590
|
getUpdateInfo: function () {
|
|
2350
2591
|
return updateInfoPromise
|
|
2351
2592
|
},
|
|
2352
|
-
getBalanceSnapshot: function (args) {
|
|
2593
|
+
getBalanceSnapshot: async function (args) {
|
|
2353
2594
|
const pid = args && typeof args === 'object' && args.provider ? String(args.provider) : '';
|
|
2595
|
+
// force:客户端刚打开/刷新网页时的强制刷新——当场重查服务商,不等 60s 周期缓存
|
|
2596
|
+
const force = !!(args && typeof args === 'object' && args.force === true);
|
|
2597
|
+
if (force) {
|
|
2598
|
+
const key = balanceProviderKey(pid || undefined);
|
|
2599
|
+
if (key) await refreshProviderBalance(key);
|
|
2600
|
+
}
|
|
2354
2601
|
return activeBalanceSummary(pid || undefined, Date.now());
|
|
2355
2602
|
},
|
|
2356
2603
|
getPricing: async function (args) {
|
|
@@ -2394,11 +2641,13 @@ export default {
|
|
|
2394
2641
|
return Object.assign(detectBillingMode(sel.provider, config.billingMode), { model: sel.model });
|
|
2395
2642
|
},
|
|
2396
2643
|
getSubscriptionSnapshot: function (args) {
|
|
2397
|
-
|
|
2644
|
+
const force = !!(args && typeof args === 'object' && args.force === true);
|
|
2645
|
+
return getSubscriptionSnapshotRpc(selectionFromArgs(args), force);
|
|
2398
2646
|
},
|
|
2399
2647
|
// v1.7 FR-14:账单型快照(云账单 provider)
|
|
2400
2648
|
getBillingStatus: function (args) {
|
|
2401
|
-
|
|
2649
|
+
const force = !!(args && typeof args === 'object' && args.force === true);
|
|
2650
|
+
return getBillingSnapshotRpc(selectionFromArgs(args), force);
|
|
2402
2651
|
},
|
|
2403
2652
|
setDisplayMode: function (args) {
|
|
2404
2653
|
const mode = args && typeof args === 'object' ? args.mode : null;
|
|
@@ -2500,7 +2749,13 @@ export default {
|
|
|
2500
2749
|
}, 'dsh-bottom-info-bar: Web routes');
|
|
2501
2750
|
});
|
|
2502
2751
|
|
|
2503
|
-
// ---------- 启动即刷 +
|
|
2752
|
+
// ---------- 启动即刷 + 定时刷新 ----------
|
|
2753
|
+
// 价目目录:① 离线兜底(磁盘缓存先同步合并)→ ② 回填 → ③ 异步拉远程增量(成功后再回填一次)
|
|
2754
|
+
const cachedPricing = loadPricingCacheFromDisk();
|
|
2755
|
+
if (cachedPricing) applyRemotePricingEntries(cachedPricing);
|
|
2756
|
+
backfillUnpricedRecords(); // 内置表已覆盖的部分先补算
|
|
2757
|
+
refreshRemotePricing('启动'); // 网络刷新异步进行;成功后内部会再次触发回填
|
|
2758
|
+
ctx.interval(function () { refreshRemotePricing('定时'); }, REMOTE_PRICING_REFRESH_MS);
|
|
2504
2759
|
refreshAllBalances();
|
|
2505
2760
|
refreshActiveSubscriptions(); // 惰性:无客户端请求过订阅源则不发起网络请求
|
|
2506
2761
|
refreshActiveBilling(); // v1.7:账单型同样惰性
|
package/package.json
CHANGED