dsh-bottom-info-bar 1.3.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/LICENSE +21 -0
- package/README.md +9 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +659 -0
- package/lib/index.js +1285 -0
- package/package.json +57 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
// Generated by scripts/build.mjs from src/host.js — do not edit directly.
|
|
2
|
+
// Bottom Info Bar(底部信息栏插件)— host half(静态 bundle 形态)
|
|
3
|
+
// 业务:余额真实 API / 峰谷定价 / llm/stream 记账 / 会话聚合 / 显示名识别 / 订阅额度显示
|
|
4
|
+
// RPC:webServer HTTP 路由(GET/POST /_dsh/dsh-bottom-info-bar/<method>,JSON 进出,同源防护)
|
|
5
|
+
// 依赖:inject ['credentials', 'timer'];可选服务 webServer(ctx.inject 等待)
|
|
6
|
+
// 记账持久化:usageRecords 落盘 ~/.dsh/dsh-bottom-info-bar/usage-records.json(可用环境变量
|
|
7
|
+
// DSH_BOTTOM_INFO_BAR_DATA_DIR 覆盖目录),重启后真实累计花费不丢失。
|
|
8
|
+
// 订阅额度:本插件只读令牌(~/.codex/auth.json / opencode auth.json)查询额度、仅作显示;
|
|
9
|
+
// 令牌的绑定/续期/写回由独立插件 dsh-chatgpt-subscription 维护,本插件不写回、不续期、不注入凭据。
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const DATA_DIR = process.env.DSH_BOTTOM_INFO_BAR_DATA_DIR || join(homedir(), '.dsh', 'dsh-bottom-info-bar')
|
|
15
|
+
const DATA_FILE = join(DATA_DIR, 'usage-records.json')
|
|
16
|
+
|
|
17
|
+
// ---------- 双模式(余额制 / 订阅制)配置 ----------
|
|
18
|
+
// 订阅制 provider 集合:这些 provider 走"额度窗口"显示而非余额(可在此增删)
|
|
19
|
+
const SUBSCRIPTION_PROVIDERS = ['codex', 'chatgpt', 'opencode-go', 'opencode', 'openai-codex']
|
|
20
|
+
// 订阅窗口时长(秒):5 小时 / 7 天 / 30 天;映射带 5% 容差(接口值可能微调)
|
|
21
|
+
const WINDOW_SECONDS = { five_hour: 18000, seven_day: 604800, monthly: 2592000 }
|
|
22
|
+
const WINDOW_LABELS = { five_hour: '5小时', seven_day: '周', monthly: '月' }
|
|
23
|
+
// 订阅窗口预警阈值由客户端本判定(剩余 ≤20% → 琥珀 ⚠,见 client 的 LOW_QUOTA_PERCENT);
|
|
24
|
+
// host 仅下发额度/重置数据,不重复判定,故移除原 WINDOW_ALERT_PERCENT=90 的死常量。
|
|
25
|
+
const CODEX_PLAN_NAMES = { plus: 'ChatGPT Plus', pro: 'ChatGPT Pro', team: 'ChatGPT Team', enterprise: 'ChatGPT Enterprise' }
|
|
26
|
+
const SUBSCRIPTION_REFRESH_MS = 60000 // 订阅额度快照刷新周期(与余额一致)
|
|
27
|
+
const SUBSCRIPTION_RETRY_BACKOFF_MS = 60000 // 订阅刷新失败后退避期:期内不重试(减少对未公开 wham 接口的请求 + 避免"刷新失败"提示闪烁)
|
|
28
|
+
// 订阅源 auth 文件路径(可用环境变量覆盖——测试隔离用,避免测试误读真实登录态);
|
|
29
|
+
// 本插件只读令牌查询额度,令牌的绑定/续期由独立插件 dsh-chatgpt-subscription 维护
|
|
30
|
+
const CODEX_AUTH_FILE = process.env.DSH_BOTTOM_INFO_BAR_CODEX_AUTH || join(homedir(), '.codex', 'auth.json')
|
|
31
|
+
const OPENCODE_AUTH_FILE = process.env.DSH_BOTTOM_INFO_BAR_OPENCODE_AUTH || join(homedir(), '.local', 'share', 'opencode', 'auth.json')
|
|
32
|
+
|
|
33
|
+
// ---------- 双模式纯逻辑(模式检测 / 窗口映射 / 响应解析;单测直接提取) ----------
|
|
34
|
+
|
|
35
|
+
// 窗口时长(秒)→ 窗口键:18000≈5小时 / 604800≈7天 / 2592000≈30天,5% 容差;未知返回 null
|
|
36
|
+
function codexWindowKey(limitWindowSeconds) {
|
|
37
|
+
if (typeof limitWindowSeconds !== 'number' || !isFinite(limitWindowSeconds)) return null
|
|
38
|
+
for (const key in WINDOW_SECONDS) {
|
|
39
|
+
const target = WINDOW_SECONDS[key]
|
|
40
|
+
if (Math.abs(limitWindowSeconds - target) / target <= 0.05) return key
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 订阅 provider → 订阅源标识(codex / opencode-go);非订阅 provider → null
|
|
46
|
+
function subscriptionSourceFor(providerId) {
|
|
47
|
+
if (providerId === 'codex' || providerId === 'chatgpt' || providerId === 'openai-codex') return 'codex'
|
|
48
|
+
if (providerId === 'opencode-go' || providerId === 'opencode') return 'opencode-go'
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- M5:DSH 目录名 → 展示名(模型名/服务商名与模型切换器完全一致) ----------
|
|
53
|
+
// 模型切换器显示 DSH LLM 目录的 model.name(如 id=deepseek-v4-flash 的 name="DeepSeek-V4-Flash")。
|
|
54
|
+
// 以下两个纯函数只做"缓存优先 → 回退"解析;缓存由 apply 内异步填充(llm.listModels / llm.listProviders)。
|
|
55
|
+
// modelDisplay:优先缓存里的 DSH 目录 name;缓存缺失/未知模型回退原始 model id(不做自建美化)
|
|
56
|
+
function modelDisplayFromCache(model, provider, cache) {
|
|
57
|
+
if (model && provider && cache) {
|
|
58
|
+
const provMap = cache[provider]
|
|
59
|
+
if (provMap && typeof provMap[model] === 'string' && provMap[model].length > 0) return provMap[model]
|
|
60
|
+
}
|
|
61
|
+
return model || '未知模型'
|
|
62
|
+
}
|
|
63
|
+
// providerDisplay:优先 DSH 目录 name(llm.listProviders());缺失回退静态映射;再回退大写首字母
|
|
64
|
+
function providerDisplayFromCache(providerId, cache, staticMap) {
|
|
65
|
+
if (!providerId) return '未知服务商'
|
|
66
|
+
if (cache && typeof cache[providerId] === 'string' && cache[providerId].length > 0) return cache[providerId]
|
|
67
|
+
if (staticMap && staticMap[providerId]) return staticMap[providerId]
|
|
68
|
+
return providerId.charAt(0).toUpperCase() + providerId.slice(1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 余额制/订阅制判定:billingMode='auto' 按 provider 检测;'balance'/'subscription' 手动强制覆盖
|
|
72
|
+
function detectBillingMode(providerId, billingMode) {
|
|
73
|
+
if (billingMode === 'balance' || billingMode === 'subscription') {
|
|
74
|
+
return { mode: billingMode, provider: providerId || '', reason: 'manual-override' }
|
|
75
|
+
}
|
|
76
|
+
const sub = SUBSCRIPTION_PROVIDERS.indexOf(providerId) >= 0
|
|
77
|
+
return { mode: sub ? 'subscription' : 'balance', provider: providerId || '', reason: 'provider:' + (providerId || 'unknown') }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// wham 响应的 plan_type → 显示名(未收录的 plan 类型按大写首字母兜底)
|
|
81
|
+
function planDisplayName(planType) {
|
|
82
|
+
if (typeof planType === 'string' && planType.length > 0) {
|
|
83
|
+
const known = CODEX_PLAN_NAMES[planType]
|
|
84
|
+
if (known) return known
|
|
85
|
+
return 'ChatGPT ' + planType.charAt(0).toUpperCase() + planType.slice(1)
|
|
86
|
+
}
|
|
87
|
+
return 'ChatGPT Plus/Pro'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 解析 Codex wham usage 响应:顶层 rate_limit.primary_window / secondary_window → 统一窗口数组
|
|
91
|
+
// (wham 响应无 usage 包装层;结构异常返回 null;窗口缺失 / 未知时长 / 无百分比自动跳过,不报错、不占位)
|
|
92
|
+
function parseCodexUsage(body) {
|
|
93
|
+
if (!body || typeof body !== 'object') return null
|
|
94
|
+
const rl = body.rate_limit
|
|
95
|
+
if (!rl || typeof rl !== 'object') return null
|
|
96
|
+
const windows = []
|
|
97
|
+
for (const slot of ['primary_window', 'secondary_window']) {
|
|
98
|
+
const win = rl[slot]
|
|
99
|
+
if (!win || typeof win !== 'object') continue
|
|
100
|
+
const key = codexWindowKey(win.limit_window_seconds)
|
|
101
|
+
if (!key) continue
|
|
102
|
+
const used = win.used_percent
|
|
103
|
+
if (typeof used !== 'number' || !isFinite(used)) continue
|
|
104
|
+
windows.push({
|
|
105
|
+
key: key,
|
|
106
|
+
label: WINDOW_LABELS[key],
|
|
107
|
+
usedPercent: Math.round(used),
|
|
108
|
+
resetsAt: typeof win.reset_at === 'number' && isFinite(win.reset_at) ? win.reset_at * 1000 : null,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
// 同一窗口键去重(primary 优先);保持出现顺序
|
|
112
|
+
const seen = {}
|
|
113
|
+
const unique = []
|
|
114
|
+
for (const w of windows) {
|
|
115
|
+
if (seen[w.key]) continue
|
|
116
|
+
seen[w.key] = true
|
|
117
|
+
unique.push(w)
|
|
118
|
+
}
|
|
119
|
+
return { plan: planDisplayName(body.plan_type), windows: unique }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// OpenCode Go 窗口键(rolling=5小时滚动窗口 / weekly / monthly)
|
|
123
|
+
function openCodeGoWindowKey(apiKey) {
|
|
124
|
+
if (apiKey === 'rolling') return 'five_hour'
|
|
125
|
+
if (apiKey === 'weekly') return 'seven_day'
|
|
126
|
+
if (apiKey === 'monthly') return 'monthly'
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 归一化重置时刻:数值(秒或毫秒)→ 毫秒;ISO 字符串 → 毫秒;无法解析 → null
|
|
131
|
+
function normalizeResetAt(value) {
|
|
132
|
+
if (typeof value === 'number' && isFinite(value)) return value < 1e12 ? value * 1000 : value
|
|
133
|
+
if (typeof value === 'string') {
|
|
134
|
+
const t = Date.parse(value)
|
|
135
|
+
return isNaN(t) ? null : t
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 解析 OpenCode Go usage 响应:usage.rolling / weekly / monthly → 统一窗口数组
|
|
141
|
+
// status 非 'ok' 的窗口跳过(如额度超限 / 接口异常);结构异常返回 null
|
|
142
|
+
function parseOpenCodeGoUsage(body) {
|
|
143
|
+
if (!body || typeof body !== 'object') return null
|
|
144
|
+
const usage = body.usage
|
|
145
|
+
if (!usage || typeof usage !== 'object') return null
|
|
146
|
+
const windows = []
|
|
147
|
+
for (const apiKey of ['rolling', 'weekly', 'monthly']) {
|
|
148
|
+
const win = usage[apiKey]
|
|
149
|
+
if (!win || typeof win !== 'object') continue
|
|
150
|
+
if (win.status !== 'ok') continue
|
|
151
|
+
const percent = win.percent
|
|
152
|
+
if (typeof percent !== 'number' || !isFinite(percent)) continue
|
|
153
|
+
const key = openCodeGoWindowKey(apiKey)
|
|
154
|
+
windows.push({
|
|
155
|
+
key: key,
|
|
156
|
+
label: WINDOW_LABELS[key],
|
|
157
|
+
usedPercent: Math.round(percent),
|
|
158
|
+
resetsAt: normalizeResetAt(win.resetsAt),
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
return { plan: 'OpenCode Go', windows: windows }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 快照更新规则("失败保留旧快照"的纯函数形态):失败保留旧 data/fetchedAt 只换 error;成功换 data 并更新 fetchedAt
|
|
165
|
+
function mergeSubscriptionResult(prev, result) {
|
|
166
|
+
if (!result || result.error) {
|
|
167
|
+
return {
|
|
168
|
+
data: prev && prev.data ? prev.data : null,
|
|
169
|
+
fetchedAt: prev && prev.fetchedAt ? prev.fetchedAt : null,
|
|
170
|
+
error: result ? result.error : { kind: 'exception', message: '订阅额度请求未知异常' },
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { data: result.data || null, fetchedAt: Date.now(), error: null }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 读 auth.json:{ ok:true, auth } 或 { ok:false, reason:'missing'|'corrupt' }(缺失/损坏一律不抛异常)
|
|
177
|
+
function readCodexAuthFile(filePath) {
|
|
178
|
+
let raw = null
|
|
179
|
+
try {
|
|
180
|
+
raw = readFileSync(filePath, 'utf8')
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return { ok: false, reason: err && err.code === 'ENOENT' ? 'missing' : 'corrupt' }
|
|
183
|
+
}
|
|
184
|
+
let auth = null
|
|
185
|
+
try {
|
|
186
|
+
auth = JSON.parse(raw)
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return { ok: false, reason: 'corrupt' }
|
|
189
|
+
}
|
|
190
|
+
if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return { ok: false, reason: 'corrupt' }
|
|
191
|
+
return { ok: true, auth: auth }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------- 订阅令牌与绑定(v1.2.0 起剥离):绑定/OAuth/续期/写回/凭据注入由独立插件
|
|
195
|
+
// dsh-chatgpt-subscription 负责;本插件只保留 readCodexAuthFile(只读令牌用于额度请求) ----------
|
|
196
|
+
|
|
197
|
+
// 记账数值清洗:写入前的最后一道闸——NaN/Infinity/负数/非数字一律归 0(不让坏数值落盘、不污染汇总)
|
|
198
|
+
function sanitizeTokens(value) {
|
|
199
|
+
return Number.isFinite(value) && value >= 0 ? value : 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 单条记账记录有效性(加载过滤):ts/input/cacheRead/cacheWrite/output 均须为有限非负数;
|
|
203
|
+
// 手改/损坏文件里的 Infinity(如 JSON 字面量 1e999)与负值记录直接丢弃,绝不进入内存汇总
|
|
204
|
+
function isValidUsageRecord(r) {
|
|
205
|
+
return !!r && typeof r === 'object' && typeof r.model === 'string' && typeof r.provider === 'string'
|
|
206
|
+
&& Number.isFinite(r.ts) && r.ts >= 0
|
|
207
|
+
&& Number.isFinite(r.input) && r.input >= 0
|
|
208
|
+
&& Number.isFinite(r.cacheRead) && r.cacheRead >= 0
|
|
209
|
+
&& Number.isFinite(r.cacheWrite) && r.cacheWrite >= 0
|
|
210
|
+
&& Number.isFinite(r.output) && r.output >= 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function loadUsageRecords() {
|
|
214
|
+
try {
|
|
215
|
+
if (!existsSync(DATA_FILE)) return []
|
|
216
|
+
const parsed = JSON.parse(readFileSync(DATA_FILE, 'utf8'))
|
|
217
|
+
if (!Array.isArray(parsed)) return []
|
|
218
|
+
return parsed.filter(isValidUsageRecord);
|
|
219
|
+
} catch (err) { /* 文件损坏/不可读 → 从空开始,不影响主流程 */ return [] }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export default {
|
|
223
|
+
inject: ['credentials', 'timer'],
|
|
224
|
+
apply(ctx) {
|
|
225
|
+
// ---------- 定价表(元/美元 · 百万 tokens;DeepSeek 官方 2026-08-17;OpenAI 为 2026 官方价示例) ----------
|
|
226
|
+
const PRICING = {
|
|
227
|
+
'deepseek-v4-flash': {
|
|
228
|
+
currency: 'CNY', mode: 'peak-valley',
|
|
229
|
+
peak: { inputCacheHit: 0.10, inputCacheMiss: 3.0, output: 9.0 },
|
|
230
|
+
offpeak:{ inputCacheHit: 0.05, inputCacheMiss: 1.5, output: 4.5 },
|
|
231
|
+
},
|
|
232
|
+
'deepseek-v4-pro': {
|
|
233
|
+
currency: 'CNY', mode: 'peak-valley',
|
|
234
|
+
peak: { inputCacheHit: 0.30, inputCacheMiss: 9.0, output: 27.0 },
|
|
235
|
+
offpeak:{ inputCacheHit: 0.15, inputCacheMiss: 4.5, output: 13.5 },
|
|
236
|
+
},
|
|
237
|
+
'deepseek-chat': { currency: 'CNY', mode: 'flat', price: { inputCacheHit: 0.5, inputCacheMiss: 2.0, output: 8.0 } },
|
|
238
|
+
'gpt-4o': { currency: 'USD', mode: 'flat', price: { inputCacheHit: 1.25, inputCacheMiss: 2.5, output: 10.0 } },
|
|
239
|
+
'gpt-4o-mini': { currency: 'USD', mode: 'flat', price: { inputCacheHit: 0.15, inputCacheMiss: 0.15, output: 0.6 } },
|
|
240
|
+
};
|
|
241
|
+
function modelCurrency(model) {
|
|
242
|
+
const entry = PRICING[model];
|
|
243
|
+
if (entry && entry.currency) return entry.currency;
|
|
244
|
+
return model && model.indexOf('gpt') === 0 ? 'USD' : 'CNY';
|
|
245
|
+
}
|
|
246
|
+
const DEFAULT_MODEL = 'deepseek-v4-flash';
|
|
247
|
+
const SCENARIOS = [
|
|
248
|
+
{ id: 'qa', label: '日常问答', outputK: 2, inputK: 4 },
|
|
249
|
+
{ id: 'coding', label: '中等编码任务', outputK: 15, inputK: 30 },
|
|
250
|
+
{ id: 'doc', label: '长文档分析/代码审查', outputK: 40, inputK: 120 },
|
|
251
|
+
{ id: 'refactor', label: '大工程重构(多轮)', outputK: 150, inputK: 500 },
|
|
252
|
+
{ id: 'subagent', label: '子代理工作流', outputK: 300, inputK: 1000 },
|
|
253
|
+
];
|
|
254
|
+
const CALIB_SESSIONS = 10;
|
|
255
|
+
const SPEND_DAYS = 7;
|
|
256
|
+
const ALERT_THRESHOLD = 20; // 默认预警阈值(¥/$)
|
|
257
|
+
|
|
258
|
+
// ---------- 服务商适配器(余额仅 DeepSeek 真实 API;OpenAI 为记账回退估算) ----------
|
|
259
|
+
const PROVIDERS = {
|
|
260
|
+
deepseek: {
|
|
261
|
+
id: 'deepseek', displayName: 'DeepSeek', credential: 'DEEPSEEK_API_KEY',
|
|
262
|
+
balanceAPI: 'https://api.deepseek.com/user/balance',
|
|
263
|
+
estimate: false,
|
|
264
|
+
parseBalance: function (body) {
|
|
265
|
+
const list = body && Array.isArray(body.balance_infos) ? body.balance_infos : [];
|
|
266
|
+
let cny = null;
|
|
267
|
+
for (let i = 0; i < list.length; i++) {
|
|
268
|
+
if (list[i].currency === 'CNY') { cny = list[i]; break; }
|
|
269
|
+
}
|
|
270
|
+
const rec = cny || list[0];
|
|
271
|
+
if (!rec) return null;
|
|
272
|
+
return {
|
|
273
|
+
currency: rec.currency || 'CNY',
|
|
274
|
+
total: parseFloat(rec.total_balance) || 0,
|
|
275
|
+
granted: parseFloat(rec.granted_balance) || 0,
|
|
276
|
+
toppedUp: parseFloat(rec.topped_up_balance) || 0,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
openai: {
|
|
281
|
+
id: 'openai', displayName: 'OpenAI', credential: 'OPENAI_API_KEY',
|
|
282
|
+
balanceAPI: null, // 无公开余额 API → 记账回退
|
|
283
|
+
estimate: true,
|
|
284
|
+
initialTopUp: 20, // USD 起始充值额(内存态)
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// ---------- 配置(内存态) ----------
|
|
289
|
+
let config = {
|
|
290
|
+
displayMode: 'replace',
|
|
291
|
+
infoDensity: 'full', // 'full' 完整 | 'compact' 简洁
|
|
292
|
+
activeProvider: 'deepseek',
|
|
293
|
+
alertThreshold: ALERT_THRESHOLD,
|
|
294
|
+
billingMode: 'auto', // 'auto' 按 provider 检测余额/订阅 | 'balance'/'subscription' 手动强制覆盖
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ---------- 余额快照(60s 定时刷新;失败保留上次快照) ----------
|
|
298
|
+
let balances = {}; // { [providerId]: { data, fetchedAt, error } }
|
|
299
|
+
|
|
300
|
+
function providerSpend(providerId) {
|
|
301
|
+
let total = 0;
|
|
302
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
303
|
+
const r = usageRecords[i];
|
|
304
|
+
if (r.provider !== providerId) continue;
|
|
305
|
+
const c = costOf(r, false);
|
|
306
|
+
if (c != null) total += c;
|
|
307
|
+
}
|
|
308
|
+
return total;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const balanceSeq = {}; // 每 provider 刷新序号:仅最新一次请求可写入快照,防慢请求覆盖新数据
|
|
312
|
+
|
|
313
|
+
function refreshProviderBalance(pid) {
|
|
314
|
+
const prov = PROVIDERS[pid];
|
|
315
|
+
if (!prov) return;
|
|
316
|
+
const seq = (balanceSeq[pid] || 0) + 1;
|
|
317
|
+
balanceSeq[pid] = seq;
|
|
318
|
+
if (!prov.balanceAPI) {
|
|
319
|
+
// 记账回退:估算余额 = 起始充值额 - 累计花费
|
|
320
|
+
const spend = providerSpend(pid);
|
|
321
|
+
const total = Math.max(0, prov.initialTopUp - spend);
|
|
322
|
+
balances[pid] = { data: { currency: 'USD', total: total, granted: 0, toppedUp: prov.initialTopUp }, fetchedAt: Date.now(), error: null };
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
(async function () {
|
|
326
|
+
let cred = null;
|
|
327
|
+
try {
|
|
328
|
+
cred = await ctx.credentials.resolve(prov.credential);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
// 与下方 http/parse/exception 分支一致:失败保留旧 data/fetchedAt,仅换 error;seq guard 防慢请求覆盖新快照
|
|
331
|
+
if (balanceSeq[pid] === seq) balances[pid] = { data: balances[pid] && balances[pid].data, fetchedAt: balances[pid] && balances[pid].fetchedAt, error: { kind: 'credentials', message: '凭据读取失败' } };
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!cred || !cred.value) {
|
|
335
|
+
// no-key 同样保留旧快照:一次瞬断/未配置不把好数据清空(客户端据 error 显示配置引导/警示)
|
|
336
|
+
if (balanceSeq[pid] === seq) balances[pid] = { data: balances[pid] && balances[pid].data, fetchedAt: balances[pid] && balances[pid].fetchedAt, error: { kind: 'no-key', message: '未配置 ' + prov.credential } };
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
// API Key 经 HTTP 头传递,不进子进程命令行(避免 ps 可见 / shell 注入)
|
|
341
|
+
const res = await fetch(prov.balanceAPI, {
|
|
342
|
+
headers: { Authorization: 'Bearer ' + cred.value },
|
|
343
|
+
signal: AbortSignal.timeout(15000),
|
|
344
|
+
});
|
|
345
|
+
if (!res.ok) {
|
|
346
|
+
if (balanceSeq[pid] === seq) balances[pid] = { data: balances[pid] && balances[pid].data, fetchedAt: balances[pid] && balances[pid].fetchedAt, error: { kind: 'http', message: '请求失败(HTTP ' + res.status + ')' } };
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const body = await res.json();
|
|
350
|
+
const parsed = prov.parseBalance(body);
|
|
351
|
+
if (!parsed) {
|
|
352
|
+
if (balanceSeq[pid] === seq) balances[pid] = { data: balances[pid] && balances[pid].data, fetchedAt: balances[pid] && balances[pid].fetchedAt, error: { kind: 'parse', message: '响应格式异常' } };
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (balanceSeq[pid] === seq) balances[pid] = { data: parsed, fetchedAt: Date.now(), error: null };
|
|
356
|
+
} catch (err) {
|
|
357
|
+
if (balanceSeq[pid] === seq) balances[pid] = { data: balances[pid] && balances[pid].data, fetchedAt: balances[pid] && balances[pid].fetchedAt, error: { kind: 'exception', message: String((err && err.message) || err) } };
|
|
358
|
+
}
|
|
359
|
+
})();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function refreshAllBalances() {
|
|
363
|
+
for (const pid in PROVIDERS) refreshProviderBalance(pid);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- 订阅额度快照(复用余额模式:周期刷新 / 失败保留旧快照 / seq 防旧覆盖) ----------
|
|
367
|
+
let subscriptions = {}; // { [sourceKey]: { data: {provider,plan,windows}, fetchedAt, error } }
|
|
368
|
+
const subscriptionSeq = {}; // 每 source 刷新序号:仅最新一次请求可写入快照
|
|
369
|
+
const subscriptionInFlight = {}; // { [sourceKey]: Promise } 并发去重(同一时刻只发一个请求)
|
|
370
|
+
const subscriptionRequested = {}; // 仅"客户端请求过"的源进入 60s 周期刷新(余额制下不打扰订阅接口)
|
|
371
|
+
const subscriptionLastFailAt = {}; // { [sourceKey]: ms } 上次订阅刷新失败时刻(失败退避:期内不重试)
|
|
372
|
+
|
|
373
|
+
async function fetchWhamUsage(token, accountId) {
|
|
374
|
+
const headers = { Authorization: 'Bearer ' + token, Accept: 'application/json' };
|
|
375
|
+
if (accountId) headers['ChatGPT-Account-Id'] = accountId;
|
|
376
|
+
const res = await fetch('https://chatgpt.com/backend-api/wham/usage', {
|
|
377
|
+
headers: headers,
|
|
378
|
+
signal: AbortSignal.timeout(15000),
|
|
379
|
+
});
|
|
380
|
+
const text = await res.text();
|
|
381
|
+
let body = null;
|
|
382
|
+
try { body = JSON.parse(text); } catch (err) { /* 非 JSON 响应体 → 交由解析层判定结构异常 */ }
|
|
383
|
+
return { ok: res.ok, status: res.status, body: body };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 只读令牌查询额度:令牌由独立插件 dsh-chatgpt-subscription 维护,本插件不续期、不写回、不注入凭据;
|
|
387
|
+
// 令牌缺失 → no-key(客户端显示"未绑定"引导);令牌失效(401)→ auth(客户端显示"重新绑定"引导)
|
|
388
|
+
async function fetchCodexUsage() {
|
|
389
|
+
const read = readCodexAuthFile(CODEX_AUTH_FILE);
|
|
390
|
+
if (!read.ok) {
|
|
391
|
+
return { error: { kind: 'no-key', message: '未找到 ChatGPT 订阅登录凭证(~/.codex/auth.json),请安装 dsh-chatgpt-subscription 插件绑定' } };
|
|
392
|
+
}
|
|
393
|
+
const tokens = read.auth && read.auth.tokens;
|
|
394
|
+
const access = tokens && tokens.access_token;
|
|
395
|
+
const accountId = tokens && tokens.account_id;
|
|
396
|
+
if (typeof access !== 'string' || access.length === 0) {
|
|
397
|
+
return { error: { kind: 'no-key', message: 'ChatGPT 订阅登录凭证缺少 access_token,请安装 dsh-chatgpt-subscription 插件重新绑定' } };
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const r = await fetchWhamUsage(access, accountId);
|
|
401
|
+
if (r.status === 401) {
|
|
402
|
+
return { error: { kind: 'auth', message: 'ChatGPT 订阅令牌已失效,请在 dsh-chatgpt-subscription 插件中重新绑定' } };
|
|
403
|
+
}
|
|
404
|
+
if (!r.ok) return { error: { kind: 'http', message: '请求失败(HTTP ' + r.status + ')' } };
|
|
405
|
+
const parsed = parseCodexUsage(r.body);
|
|
406
|
+
if (!parsed) return { error: { kind: 'parse', message: '响应格式异常' } };
|
|
407
|
+
return { data: { provider: 'codex', plan: parsed.plan, windows: parsed.windows } };
|
|
408
|
+
} catch (err) {
|
|
409
|
+
return { error: { kind: 'exception', message: String((err && err.message) || err) } };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// OpenCode Go key 解析:DSH credentials(OPENCODE_GO_API_KEY)→ opencode auth.json(opencode-go → opencode)
|
|
414
|
+
async function resolveOpenCodeGoKey() {
|
|
415
|
+
try {
|
|
416
|
+
const cred = await ctx.credentials.resolve('OPENCODE_GO_API_KEY');
|
|
417
|
+
if (cred && typeof cred.value === 'string' && cred.value.length > 0) return cred.value;
|
|
418
|
+
} catch (err) { /* 回退到 auth.json */ }
|
|
419
|
+
try {
|
|
420
|
+
const auth = JSON.parse(readFileSync(OPENCODE_AUTH_FILE, 'utf8'));
|
|
421
|
+
for (const name of ['opencode-go', 'opencode']) {
|
|
422
|
+
const entry = auth && auth[name];
|
|
423
|
+
if (entry && typeof entry === 'object') {
|
|
424
|
+
if (typeof entry.key === 'string' && entry.key.length > 0) return entry.key;
|
|
425
|
+
if (typeof entry.apiKey === 'string' && entry.apiKey.length > 0) return entry.apiKey;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (err) { /* 未配置 → 返回 null */ }
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function fetchOpenCodeGoUsage() {
|
|
433
|
+
const key = await resolveOpenCodeGoKey();
|
|
434
|
+
if (!key) {
|
|
435
|
+
return { error: { kind: 'no-key', message: '未配置 OpenCode Go(OPENCODE_GO_API_KEY 或 opencode auth.json)' } };
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
const res = await fetch('https://opencode.ai/zen/go/v1/usage', {
|
|
439
|
+
headers: { Authorization: 'Bearer ' + key },
|
|
440
|
+
signal: AbortSignal.timeout(15000),
|
|
441
|
+
});
|
|
442
|
+
if (!res.ok) return { error: { kind: 'http', message: '请求失败(HTTP ' + res.status + ')' } };
|
|
443
|
+
const body = await res.json();
|
|
444
|
+
const parsed = parseOpenCodeGoUsage(body);
|
|
445
|
+
if (!parsed) return { error: { kind: 'parse', message: '响应格式异常' } };
|
|
446
|
+
return { data: { provider: 'opencode-go', plan: parsed.plan, windows: parsed.windows } };
|
|
447
|
+
} catch (err) {
|
|
448
|
+
return { error: { kind: 'exception', message: String((err && err.message) || err) } };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const SUBSCRIPTION_SOURCES = {
|
|
453
|
+
codex: { fetch: fetchCodexUsage },
|
|
454
|
+
'opencode-go': { fetch: fetchOpenCodeGoUsage },
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// 触发一次刷新(并发去重 + seq 防旧覆盖);返回本次刷新 Promise
|
|
458
|
+
function kickSubscriptionRefresh(sourceKey) {
|
|
459
|
+
const src = SUBSCRIPTION_SOURCES[sourceKey];
|
|
460
|
+
if (!src) return Promise.resolve();
|
|
461
|
+
if (subscriptionInFlight[sourceKey]) return subscriptionInFlight[sourceKey];
|
|
462
|
+
const seq = (subscriptionSeq[sourceKey] || 0) + 1;
|
|
463
|
+
subscriptionSeq[sourceKey] = seq;
|
|
464
|
+
subscriptionInFlight[sourceKey] = src.fetch().then(function (result) {
|
|
465
|
+
if (subscriptionSeq[sourceKey] === seq) {
|
|
466
|
+
subscriptions[sourceKey] = mergeSubscriptionResult(subscriptions[sourceKey], result);
|
|
467
|
+
// 失败退避记录:失败记时刻(期内不重试),成功清零
|
|
468
|
+
if (result && result.error) subscriptionLastFailAt[sourceKey] = Date.now();
|
|
469
|
+
else subscriptionLastFailAt[sourceKey] = 0;
|
|
470
|
+
}
|
|
471
|
+
}).catch(function (err) {
|
|
472
|
+
if (subscriptionSeq[sourceKey] === seq) {
|
|
473
|
+
subscriptions[sourceKey] = mergeSubscriptionResult(subscriptions[sourceKey], {
|
|
474
|
+
error: { kind: 'exception', message: String((err && err.message) || err) },
|
|
475
|
+
});
|
|
476
|
+
subscriptionLastFailAt[sourceKey] = Date.now();
|
|
477
|
+
}
|
|
478
|
+
}).finally(function () {
|
|
479
|
+
subscriptionInFlight[sourceKey] = null;
|
|
480
|
+
});
|
|
481
|
+
return subscriptionInFlight[sourceKey];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 60s 周期刷新:仅刷新客户端请求过的源(余额制模式下不打扰未公开的订阅接口);失败退避期内跳过
|
|
485
|
+
function refreshActiveSubscriptions() {
|
|
486
|
+
const nowMs = Date.now();
|
|
487
|
+
for (const sourceKey in SUBSCRIPTION_SOURCES) {
|
|
488
|
+
if (!subscriptionRequested[sourceKey]) continue;
|
|
489
|
+
const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
|
|
490
|
+
if (nowMs - lastFailAt < SUBSCRIPTION_RETRY_BACKOFF_MS) continue;
|
|
491
|
+
kickSubscriptionRefresh(sourceKey);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// RPC:当前订阅额度快照 + 模式判定(非订阅模式直接返回,不发任何订阅请求)
|
|
496
|
+
async function getSubscriptionSnapshotRpc() {
|
|
497
|
+
const sel = modelSelection();
|
|
498
|
+
const bm = detectBillingMode(sel.provider, config.billingMode);
|
|
499
|
+
const out = { mode: bm.mode, provider: sel.provider, reason: bm.reason, source: null, plan: null, windows: [], fetchedAt: null, error: null };
|
|
500
|
+
if (bm.mode !== 'subscription') return out;
|
|
501
|
+
const sourceKey = subscriptionSourceFor(sel.provider);
|
|
502
|
+
if (!sourceKey) return out;
|
|
503
|
+
out.source = sourceKey;
|
|
504
|
+
subscriptionRequested[sourceKey] = true; // 该源进入 60s 周期刷新
|
|
505
|
+
const snap = subscriptions[sourceKey] || { data: null, fetchedAt: null, error: null };
|
|
506
|
+
const nowMs = Date.now();
|
|
507
|
+
const lastFailAt = subscriptionLastFailAt[sourceKey] || 0;
|
|
508
|
+
// 失败退避:快照过期(>60s 无成功)且距上次失败 ≥ 退避期(60s)才重试——
|
|
509
|
+
// 减少对未公开 wham 接口的请求,也避免"刷新失败"提示随每次轮询反复闪烁(失败期内直接读缓存快照)
|
|
510
|
+
const stale = (!snap.fetchedAt || (nowMs - snap.fetchedAt) > SUBSCRIPTION_REFRESH_MS)
|
|
511
|
+
&& (nowMs - lastFailAt) >= SUBSCRIPTION_RETRY_BACKOFF_MS;
|
|
512
|
+
if (stale) {
|
|
513
|
+
const inflight = kickSubscriptionRefresh(sourceKey);
|
|
514
|
+
// 从未成功过(无旧数据)→ 等本次刷新返回最新结果(含错误),避免退避重试后仍返回旧失败快照;
|
|
515
|
+
// 已有旧数据 → 后台刷新,本次直接返回快照(不阻塞轮询)
|
|
516
|
+
if (!snap.data) await inflight;
|
|
517
|
+
}
|
|
518
|
+
const cur = subscriptions[sourceKey] || { data: null, fetchedAt: null, error: null };
|
|
519
|
+
if (cur.data) { out.plan = cur.data.plan; out.windows = cur.data.windows; }
|
|
520
|
+
out.fetchedAt = cur.fetchedAt;
|
|
521
|
+
out.error = cur.error;
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------- 北京时间峰谷判定 ----------
|
|
526
|
+
function beijingMinutes(nowMs) {
|
|
527
|
+
const d = new Date(nowMs + 8 * 3600 * 1000);
|
|
528
|
+
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
529
|
+
}
|
|
530
|
+
function currentPeriod(nowMs) {
|
|
531
|
+
const m = beijingMinutes(nowMs);
|
|
532
|
+
return (m >= 9 * 60 && m < 12 * 60) || (m >= 14 * 60 && m < 18 * 60) ? 'peak' : 'offpeak';
|
|
533
|
+
}
|
|
534
|
+
function nextSwitchAt(nowMs) {
|
|
535
|
+
const d = new Date(nowMs + 8 * 3600 * 1000);
|
|
536
|
+
const cur = d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
537
|
+
const bounds = [9, 12, 14, 18];
|
|
538
|
+
for (let i = 0; i < bounds.length; i++) {
|
|
539
|
+
const b = bounds[i] * 60;
|
|
540
|
+
if (b > cur) {
|
|
541
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), bounds[i], 0, 0) - 8 * 3600 * 1000).getTime();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 9, 0, 0) - 8 * 3600 * 1000).getTime();
|
|
545
|
+
}
|
|
546
|
+
function nextPeriodLabel(nowMs) {
|
|
547
|
+
const at = nextSwitchAt(nowMs);
|
|
548
|
+
const d = new Date(at + 8 * 3600 * 1000);
|
|
549
|
+
const hh = String(d.getUTCHours()).padStart(2, '0');
|
|
550
|
+
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
|
551
|
+
return { at: at, atLabel: hh + ':' + mm, nextIsPeak: currentPeriod(at) === 'peak' };
|
|
552
|
+
}
|
|
553
|
+
function beijingDayKey(ts) {
|
|
554
|
+
const d = new Date(ts + 8 * 3600 * 1000);
|
|
555
|
+
return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---------- 当前模型识别 ----------
|
|
559
|
+
function modelSelection() {
|
|
560
|
+
const svc = ctx.get('agentDefaultModel');
|
|
561
|
+
let fallback = false;
|
|
562
|
+
let provider = '';
|
|
563
|
+
let model = DEFAULT_MODEL;
|
|
564
|
+
if (svc && typeof svc.currentSelection === 'function') {
|
|
565
|
+
try {
|
|
566
|
+
const s = svc.currentSelection();
|
|
567
|
+
if (s && typeof s.model === 'string' && s.model.length > 0) {
|
|
568
|
+
provider = typeof s.provider === 'string' ? s.provider : '';
|
|
569
|
+
model = s.model;
|
|
570
|
+
} else {
|
|
571
|
+
fallback = true;
|
|
572
|
+
}
|
|
573
|
+
} catch (err) {
|
|
574
|
+
fallback = true;
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
fallback = true;
|
|
578
|
+
}
|
|
579
|
+
return { provider: provider, model: model, fallback: fallback };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------- 服务商显示名静态映射(M5 起为 providerDisplayFromCache 的回退层) ----------
|
|
583
|
+
const PROVIDER_DISPLAY = {
|
|
584
|
+
deepseek: 'DeepSeek',
|
|
585
|
+
'deepseek-official': 'DeepSeek',
|
|
586
|
+
openrouter: 'OpenRouter',
|
|
587
|
+
openai: 'OpenAI',
|
|
588
|
+
moonshot: 'Moonshot', // Kimi
|
|
589
|
+
zhipu: 'Zhipu', // GLM
|
|
590
|
+
glm: 'GLM',
|
|
591
|
+
kimi: 'Kimi',
|
|
592
|
+
qwen: 'Qwen',
|
|
593
|
+
anthropic: 'Anthropic',
|
|
594
|
+
google: 'Google',
|
|
595
|
+
gemini: 'Gemini',
|
|
596
|
+
mistral: 'Mistral',
|
|
597
|
+
xai: 'xAI',
|
|
598
|
+
groq: 'Groq',
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// ---------- DSH 模型/服务商目录名缓存(M5:与模型切换器完全一致) ----------
|
|
602
|
+
// llm.listModels(provider) → DSH LLM 目录 { id, name };llm.listProviders() → { id, name }。
|
|
603
|
+
// 缓存异步填充:启动即刷 + llm/adapters-updated 事件刷新 + getPricing 首次缺缓存时按需等待;
|
|
604
|
+
// llm 服务缺失/查询失败保留旧缓存(stale 可接受),展示层回退原始 id / 静态映射,绝不崩溃。
|
|
605
|
+
let modelNameCache = {}; // { provider: { modelId: name } }
|
|
606
|
+
let providerNameCache = {}; // { provider: name }
|
|
607
|
+
let modelCatalogRefreshed = {}; // { provider: true } 已尝试刷新(防 getPricing 反复打目录)
|
|
608
|
+
|
|
609
|
+
async function refreshModelCatalog(provider) {
|
|
610
|
+
const llm = ctx.get ? ctx.get('llm') : null;
|
|
611
|
+
if (!llm || typeof llm.listModels !== 'function' || !provider) return;
|
|
612
|
+
try {
|
|
613
|
+
const models = await llm.listModels(provider);
|
|
614
|
+
const map = {};
|
|
615
|
+
if (Array.isArray(models)) {
|
|
616
|
+
for (let i = 0; i < models.length; i++) {
|
|
617
|
+
const m = models[i];
|
|
618
|
+
if (m && typeof m.id === 'string' && m.id.length > 0 && typeof m.name === 'string' && m.name.length > 0) map[m.id] = m.name;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
modelNameCache[provider] = map;
|
|
622
|
+
} catch (err) { /* 目录查询失败保留旧缓存,绝不崩溃 */ }
|
|
623
|
+
try {
|
|
624
|
+
const provs = typeof llm.listProviders === 'function' ? await llm.listProviders() : null;
|
|
625
|
+
if (Array.isArray(provs)) {
|
|
626
|
+
for (let i = 0; i < provs.length; i++) {
|
|
627
|
+
const p = provs[i];
|
|
628
|
+
if (p && typeof p.id === 'string' && p.id.length > 0 && typeof p.name === 'string' && p.name.length > 0) providerNameCache[p.id] = p.name;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
} catch (err) { /* 同上 */ }
|
|
632
|
+
modelCatalogRefreshed[provider] = true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// 刷新当前激活 provider 的目录名缓存(启动 / llm/adapters-updated / 切模型后按需调用)
|
|
636
|
+
function refreshActiveModelCatalog() {
|
|
637
|
+
const sel = modelSelection();
|
|
638
|
+
return refreshModelCatalog(sel.provider);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---------- 定价计算 ----------
|
|
642
|
+
function computePricing(nowMs) {
|
|
643
|
+
const sel = modelSelection();
|
|
644
|
+
const entry = PRICING[sel.model];
|
|
645
|
+
const period = entry && entry.mode === 'peak-valley' ? currentPeriod(nowMs) : 'flat';
|
|
646
|
+
let prices = null;
|
|
647
|
+
if (entry) {
|
|
648
|
+
prices = entry.mode === 'peak-valley' ? entry[period] : entry.price;
|
|
649
|
+
}
|
|
650
|
+
const switchInfo = entry && entry.mode === 'peak-valley' ? nextPeriodLabel(nowMs) : null;
|
|
651
|
+
return {
|
|
652
|
+
model: sel.model,
|
|
653
|
+
provider: sel.provider,
|
|
654
|
+
providerDisplay: providerDisplayFromCache(sel.provider, providerNameCache, PROVIDER_DISPLAY),
|
|
655
|
+
modelDisplay: modelDisplayFromCache(sel.model, sel.provider, modelNameCache),
|
|
656
|
+
fallback: sel.fallback || !entry,
|
|
657
|
+
mode: entry ? entry.mode : 'unknown',
|
|
658
|
+
period: period,
|
|
659
|
+
prices: prices,
|
|
660
|
+
nextSwitch: switchInfo,
|
|
661
|
+
refreshedAt: nowMs,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---------- 当前激活服务商余额(含预警) ----------
|
|
666
|
+
// 模型目录 provider id → 余额账户 key:DSH 目录常用 deepseek-official / openai-codex 等标识,
|
|
667
|
+
// 余额账户按服务商聚合(deepseek-official → deepseek 同一账户);未知 → 回退 config.activeProvider。
|
|
668
|
+
// 目的:余额/币种跟随"活跃模型的服务商",避免 OpenAI 模型激活时仍显示 DeepSeek ¥ 余额与 ¥0 花费。
|
|
669
|
+
function balanceProviderKey(pid) {
|
|
670
|
+
if (!pid) return config.activeProvider;
|
|
671
|
+
if (Object.hasOwn(PROVIDERS, pid)) return pid;
|
|
672
|
+
if (pid.indexOf('deepseek') === 0) return 'deepseek';
|
|
673
|
+
if (pid.indexOf('openai') === 0) return 'openai';
|
|
674
|
+
return config.activeProvider;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function activeBalanceSummary(providerId, nowMs) {
|
|
678
|
+
// 默认跟随活跃模型的服务商(而非恒 deepseek);显式 providerId(RPC 传参)优先
|
|
679
|
+
const pid = balanceProviderKey(providerId || modelSelection().provider || config.activeProvider);
|
|
680
|
+
const prov = PROVIDERS[pid] || PROVIDERS.deepseek;
|
|
681
|
+
const snap = balances[pid] || { data: null, fetchedAt: null, error: null };
|
|
682
|
+
let alert = null;
|
|
683
|
+
if (snap.data && snap.data.total != null) {
|
|
684
|
+
const threshold = config.alertThreshold;
|
|
685
|
+
const total = snap.data.total;
|
|
686
|
+
alert = {
|
|
687
|
+
active: total < threshold,
|
|
688
|
+
threshold: threshold,
|
|
689
|
+
currency: snap.data.currency || 'CNY',
|
|
690
|
+
total: total,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
provider: prov.id,
|
|
695
|
+
displayName: prov.displayName,
|
|
696
|
+
estimate: !!prov.estimate,
|
|
697
|
+
currency: snap.data ? snap.data.currency : (prov.id === 'deepseek' ? 'CNY' : 'USD'),
|
|
698
|
+
data: snap.data,
|
|
699
|
+
fetchedAt: snap.fetchedAt,
|
|
700
|
+
error: snap.error,
|
|
701
|
+
alert: alert,
|
|
702
|
+
now: nowMs,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---------- 用量记账(llm/stream waterfall;落盘持久化,重启不丢失) ----------
|
|
707
|
+
let usageRecords = loadUsageRecords(); // { ts, model, provider, sessionId, purpose, input, cacheRead, cacheWrite, output }
|
|
708
|
+
let saveDisposer = null;
|
|
709
|
+
let dirty = false;
|
|
710
|
+
|
|
711
|
+
function flushSave() {
|
|
712
|
+
if (saveDisposer) { saveDisposer(); saveDisposer = null; }
|
|
713
|
+
try {
|
|
714
|
+
mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
|
715
|
+
// 原子写:先写临时文件再 rename,避免进程崩溃/写一半留下损坏 JSON 导致历史归零
|
|
716
|
+
const tmp = DATA_FILE + '.tmp';
|
|
717
|
+
writeFileSync(tmp, JSON.stringify(usageRecords), { mode: 0o600 });
|
|
718
|
+
renameSync(tmp, DATA_FILE);
|
|
719
|
+
dirty = false; // 写盘成功后才清除脏标记:失败时保留,卸载冲刷可重试
|
|
720
|
+
} catch (err) { /* 落盘失败不影响主流程;保留 dirty,下次记账/卸载时重试 */ console.warn('[dsh-bottom-info-bar] 记账落盘失败', String((err && err.message) || err)); }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 防抖落盘:记账后 4s 内合并写入;插件卸载时立即冲刷
|
|
724
|
+
function scheduleSave() {
|
|
725
|
+
dirty = true;
|
|
726
|
+
if (saveDisposer) return;
|
|
727
|
+
saveDisposer = ctx.timeout(function () {
|
|
728
|
+
saveDisposer = null;
|
|
729
|
+
if (dirty) flushSave();
|
|
730
|
+
}, 4000);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function recordUsage(options, usage) {
|
|
734
|
+
const u = usage || {};
|
|
735
|
+
// 数值清洗:uncachedInputTokens 的 != null 对 NaN 恒真、|| 0 挡不住 Infinity/负值——
|
|
736
|
+
// 统一 sanitizeTokens(NaN/Infinity/负数/非数字 → 0),坏数值不进内存汇总也不落盘
|
|
737
|
+
const rec = {
|
|
738
|
+
ts: Date.now(),
|
|
739
|
+
model: options.model || '',
|
|
740
|
+
provider: options.provider || '',
|
|
741
|
+
sessionId: options.sessionId || '',
|
|
742
|
+
purpose: options.purpose || '',
|
|
743
|
+
input: sanitizeTokens(u.uncachedInputTokens != null ? u.uncachedInputTokens : u.inputTokens),
|
|
744
|
+
cacheRead: sanitizeTokens(u.cacheReadTokens),
|
|
745
|
+
cacheWrite: sanitizeTokens(u.cacheWriteTokens),
|
|
746
|
+
output: sanitizeTokens(u.outputTokens),
|
|
747
|
+
};
|
|
748
|
+
usageRecords.push(rec);
|
|
749
|
+
if (usageRecords.length > 3000) usageRecords.splice(0, usageRecords.length - 3000);
|
|
750
|
+
scheduleSave();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
ctx.on('llm/stream', async function* (options, next) {
|
|
754
|
+
let stream;
|
|
755
|
+
try {
|
|
756
|
+
stream = await next();
|
|
757
|
+
} catch (err) {
|
|
758
|
+
console.warn('[dsh-bottom-info-bar] llm/stream 获取失败,本次不记账', String((err && err.message) || err));
|
|
759
|
+
throw err; // 保持错误向上传播:不把上游失败消化成空流(仅跳过记账逻辑)
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
for await (const chunk of stream) {
|
|
763
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) {
|
|
764
|
+
try { recordUsage(options, chunk.usage); } catch (err) { /* 记账失败不影响请求 */ }
|
|
765
|
+
}
|
|
766
|
+
yield chunk;
|
|
767
|
+
}
|
|
768
|
+
} catch (err) {
|
|
769
|
+
throw err;
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// M5:适配器/目录变更(模型增删、provider 改名)→ 重建目录名缓存,信息栏模型名与切换器保持一致
|
|
774
|
+
ctx.on('llm/adapters-updated', function () {
|
|
775
|
+
modelCatalogRefreshed = {};
|
|
776
|
+
refreshActiveModelCatalog();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// ---------- 花费计算 ----------
|
|
780
|
+
function costOf(record, forceOffpeak) {
|
|
781
|
+
const entry = PRICING[record.model];
|
|
782
|
+
if (!entry) return null;
|
|
783
|
+
let p;
|
|
784
|
+
if (entry.mode === 'peak-valley') {
|
|
785
|
+
p = forceOffpeak ? entry.offpeak : entry[currentPeriod(record.ts)];
|
|
786
|
+
} else {
|
|
787
|
+
p = entry.price;
|
|
788
|
+
}
|
|
789
|
+
const missInput = record.input + record.cacheWrite;
|
|
790
|
+
const cost = (missInput * p.inputCacheMiss + record.cacheRead * p.inputCacheHit + record.output * p.output) / 1e6;
|
|
791
|
+
// 结果非有限(任一字段 NaN/Infinity/缺失)→ null;调用方统一 `c != null` 判空即排除,NaN 不会累加进任何汇总
|
|
792
|
+
return Number.isFinite(cost) ? cost : null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ---------- 会话聚合与中位数 ----------
|
|
796
|
+
function median(arr) {
|
|
797
|
+
if (!arr || arr.length === 0) return 0;
|
|
798
|
+
const s = arr.slice().sort(function (a, b) { return a - b; });
|
|
799
|
+
const mid = Math.floor(s.length / 2);
|
|
800
|
+
return s.length % 2 === 1 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function sessionTotals() {
|
|
804
|
+
const map = new Map();
|
|
805
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
806
|
+
const r = usageRecords[i];
|
|
807
|
+
const key = r.sessionId || (r.provider + '/' + r.model + '#' + r.ts);
|
|
808
|
+
let s = map.get(key);
|
|
809
|
+
if (!s) {
|
|
810
|
+
s = { sessionId: r.sessionId, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, costs: {}, lastTs: r.ts };
|
|
811
|
+
map.set(key, s);
|
|
812
|
+
}
|
|
813
|
+
s.input += r.input;
|
|
814
|
+
s.cacheRead += r.cacheRead;
|
|
815
|
+
s.cacheWrite += r.cacheWrite;
|
|
816
|
+
s.output += r.output;
|
|
817
|
+
const c = costOf(r, false);
|
|
818
|
+
if (c != null) {
|
|
819
|
+
const cur = modelCurrency(r.model);
|
|
820
|
+
s.costs[cur] = (s.costs[cur] || 0) + c;
|
|
821
|
+
}
|
|
822
|
+
if (r.ts > s.lastTs) s.lastTs = r.ts;
|
|
823
|
+
}
|
|
824
|
+
return Array.from(map.values()).sort(function (a, b) { return a.lastTs - b.lastTs; });
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function calibrationFrom(sessions, n) {
|
|
828
|
+
if (!sessions || sessions.length === 0) return null;
|
|
829
|
+
const recent = sessions.slice(-n);
|
|
830
|
+
const count = recent.length;
|
|
831
|
+
return {
|
|
832
|
+
count: count,
|
|
833
|
+
label: '基于你最近 ' + count + ' 次会话',
|
|
834
|
+
medianInput: median(recent.map(function (s) { return s.input; })),
|
|
835
|
+
medianCacheRead: median(recent.map(function (s) { return s.cacheRead; })),
|
|
836
|
+
medianCacheWrite: median(recent.map(function (s) { return s.cacheWrite; })),
|
|
837
|
+
medianOutput: median(recent.map(function (s) { return s.output; })),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 会话 ID 归一化:DSH 部分路径会给 sessionId 加 'session-' 前缀,去掉后统一比较
|
|
842
|
+
function normalizeSessionId(id) {
|
|
843
|
+
if (!id) return '';
|
|
844
|
+
return String(id).replace(/^session-/, '');
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function currentSessionSummary(sessions, sessionId) {
|
|
848
|
+
if (!sessions || sessions.length === 0) return null;
|
|
849
|
+
if (!sessionId) return null; // 无可用会话 ID:不猜测归属,客户端显示 ¥0.000,而非回退最近会话
|
|
850
|
+
const norm = normalizeSessionId(sessionId);
|
|
851
|
+
let target = null;
|
|
852
|
+
for (let i = sessions.length - 1; i >= 0; i--) {
|
|
853
|
+
if (normalizeSessionId(sessions[i].sessionId) === norm) { target = sessions[i]; break; }
|
|
854
|
+
}
|
|
855
|
+
if (!target) return null; // 明确传入但未命中(新对话尚无记账)→ 客户端显示 ¥0.000
|
|
856
|
+
const denom = target.input + target.cacheRead + target.cacheWrite;
|
|
857
|
+
const tokens = target.input + target.cacheRead + target.cacheWrite + target.output;
|
|
858
|
+
return {
|
|
859
|
+
input: target.input,
|
|
860
|
+
cacheRead: target.cacheRead,
|
|
861
|
+
cacheWrite: target.cacheWrite,
|
|
862
|
+
output: target.output,
|
|
863
|
+
tokens: tokens,
|
|
864
|
+
costs: target.costs || {},
|
|
865
|
+
hitRate: denom > 0 ? Math.round((target.cacheRead / denom) * 1000) / 10 : null,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function activeCurrency() {
|
|
870
|
+
// 币种跟随活跃模型服务商(与余额账户同源):deepseek → CNY、openai → USD(估算快照);
|
|
871
|
+
// 余额快照未就绪时回退活跃模型定价币种,避免启动初期/无快照时显示错币种
|
|
872
|
+
const key = balanceProviderKey(modelSelection().provider || config.activeProvider);
|
|
873
|
+
const snap = balances[key];
|
|
874
|
+
if (snap && snap.data && snap.data.currency) return snap.data.currency;
|
|
875
|
+
return modelCurrency(modelSelection().model);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function spendSummary(nowMs) {
|
|
879
|
+
const snap = balances[balanceProviderKey(modelSelection().provider || config.activeProvider)] || { data: null };
|
|
880
|
+
const balance = snap.data ? snap.data.total : null;
|
|
881
|
+
const cur = activeCurrency();
|
|
882
|
+
const cutoff = nowMs - SPEND_DAYS * 86400 * 1000;
|
|
883
|
+
let total = 0;
|
|
884
|
+
let offpeakTotal = 0;
|
|
885
|
+
const daySet = new Set();
|
|
886
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
887
|
+
const r = usageRecords[i];
|
|
888
|
+
if (r.ts < cutoff) continue;
|
|
889
|
+
if (modelCurrency(r.model) !== cur) continue; // 只聚合活动币种,避免跨币种相加
|
|
890
|
+
const c = costOf(r, false);
|
|
891
|
+
if (c == null) continue;
|
|
892
|
+
total += c;
|
|
893
|
+
const oc = costOf(r, true);
|
|
894
|
+
if (oc != null) offpeakTotal += oc;
|
|
895
|
+
daySet.add(beijingDayKey(r.ts));
|
|
896
|
+
}
|
|
897
|
+
if (total <= 0 || balance == null) return null;
|
|
898
|
+
const daysActive = Math.max(1, daySet.size);
|
|
899
|
+
const dailySpend = total / daysActive;
|
|
900
|
+
const offpeakDailySpend = offpeakTotal / daysActive;
|
|
901
|
+
return {
|
|
902
|
+
days: SPEND_DAYS,
|
|
903
|
+
daysActive: daysActive,
|
|
904
|
+
totalSpend: Math.round(total * 100) / 100,
|
|
905
|
+
dailySpend: Math.round(dailySpend * 100) / 100,
|
|
906
|
+
balance: balance,
|
|
907
|
+
daysLeft: dailySpend > 0 ? Math.round(balance / dailySpend * 10) / 10 : null,
|
|
908
|
+
offpeakDailySpend: Math.round(offpeakDailySpend * 100) / 100,
|
|
909
|
+
offpeakDaysLeft: offpeakDailySpend > 0 ? Math.round(balance / offpeakDailySpend * 10) / 10 : null,
|
|
910
|
+
note: '基于过去 ' + SPEND_DAYS + ' 天消耗速度的估算',
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------- 今日花费(北京时间当日累计,仅活动币种) ----------
|
|
915
|
+
function todaySpend(nowMs) {
|
|
916
|
+
const key = beijingDayKey(nowMs);
|
|
917
|
+
const cur = activeCurrency();
|
|
918
|
+
let total = 0;
|
|
919
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
920
|
+
const r = usageRecords[i];
|
|
921
|
+
if (beijingDayKey(r.ts) !== key) continue;
|
|
922
|
+
if (modelCurrency(r.model) !== cur) continue;
|
|
923
|
+
const c = costOf(r, false);
|
|
924
|
+
if (c != null) total += c;
|
|
925
|
+
}
|
|
926
|
+
return Math.round(total * 1000) / 1000;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ---------- 本月/近30天花费(仅活动币种) ----------
|
|
930
|
+
function monthSpend(nowMs) {
|
|
931
|
+
const d = new Date(nowMs + 8 * 3600 * 1000);
|
|
932
|
+
const key = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
933
|
+
const cur = activeCurrency();
|
|
934
|
+
let total = 0;
|
|
935
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
936
|
+
const r = usageRecords[i];
|
|
937
|
+
const rd = new Date(r.ts + 8 * 3600 * 1000);
|
|
938
|
+
if (rd.getUTCFullYear() + '-' + String(rd.getUTCMonth() + 1).padStart(2, '0') !== key) continue;
|
|
939
|
+
if (modelCurrency(r.model) !== cur) continue;
|
|
940
|
+
const c = costOf(r, false);
|
|
941
|
+
if (c != null) total += c;
|
|
942
|
+
}
|
|
943
|
+
return Math.round(total * 1000) / 1000;
|
|
944
|
+
}
|
|
945
|
+
function last30dSpend(nowMs) {
|
|
946
|
+
const cutoff = nowMs - 30 * 86400 * 1000;
|
|
947
|
+
const cur = activeCurrency();
|
|
948
|
+
let total = 0;
|
|
949
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
950
|
+
const r = usageRecords[i];
|
|
951
|
+
if (r.ts < cutoff) continue;
|
|
952
|
+
if (modelCurrency(r.model) !== cur) continue;
|
|
953
|
+
const c = costOf(r, false);
|
|
954
|
+
if (c != null) total += c;
|
|
955
|
+
}
|
|
956
|
+
return Math.round(total * 1000) / 1000;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ---------- 场景估算 ----------
|
|
960
|
+
function scenarioCost(sc, rate, prices) {
|
|
961
|
+
const input = sc.inputK * 1000;
|
|
962
|
+
const output = sc.outputK * 1000;
|
|
963
|
+
const inputCost = input * (rate * prices.inputCacheHit + (1 - rate) * prices.inputCacheMiss);
|
|
964
|
+
const outputCost = output * prices.output;
|
|
965
|
+
return (inputCost + outputCost) / 1e6;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function computeEstimate(nowMs) {
|
|
969
|
+
const pricing = computePricing(nowMs);
|
|
970
|
+
const bal = activeBalanceSummary(config.activeProvider, nowMs);
|
|
971
|
+
const balance = bal.data ? bal.data.total : null;
|
|
972
|
+
const currency = bal.currency || 'CNY';
|
|
973
|
+
|
|
974
|
+
let conversion = null;
|
|
975
|
+
if (balance != null && pricing.prices) {
|
|
976
|
+
const p = pricing.prices;
|
|
977
|
+
const outputTokens = (balance * 1e6) / p.output;
|
|
978
|
+
const inputTokens = (balance * 1e6) / p.inputCacheMiss;
|
|
979
|
+
conversion = {
|
|
980
|
+
outputTokens: Math.floor(outputTokens),
|
|
981
|
+
outputHanzi: Math.floor(outputTokens * 0.5),
|
|
982
|
+
outputWords: Math.floor(outputTokens * 0.75),
|
|
983
|
+
outputBooks: (outputTokens * 0.5) / 200000,
|
|
984
|
+
inputTokens: Math.floor(inputTokens),
|
|
985
|
+
inputHanzi: Math.floor(inputTokens * 0.5),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let scenarios = [];
|
|
990
|
+
if (balance != null && pricing.prices) {
|
|
991
|
+
const p = pricing.prices;
|
|
992
|
+
const peakPrices = pricing.mode === 'peak-valley' ? PRICING[pricing.model].peak : p;
|
|
993
|
+
const offpeakPrices = pricing.mode === 'peak-valley' ? PRICING[pricing.model].offpeak : p;
|
|
994
|
+
scenarios = SCENARIOS.map(function (sc) {
|
|
995
|
+
return {
|
|
996
|
+
id: sc.id, label: sc.label, outputK: sc.outputK, inputK: sc.inputK,
|
|
997
|
+
optimistic: Math.floor(balance / scenarioCost(sc, 1.0, offpeakPrices)),
|
|
998
|
+
pessimistic: Math.floor(balance / scenarioCost(sc, 0, peakPrices)),
|
|
999
|
+
baseline: Math.floor(balance / scenarioCost(sc, 0.5, p)),
|
|
1000
|
+
offpeakBase: Math.floor(balance / scenarioCost(sc, 0.5, offpeakPrices)),
|
|
1001
|
+
};
|
|
1002
|
+
});
|
|
1003
|
+
const calib = calibrationFrom(sessionTotals(), CALIB_SESSIONS);
|
|
1004
|
+
if (calib && calib.medianOutput > 0) {
|
|
1005
|
+
const sc = {
|
|
1006
|
+
id: 'calibrated', label: '你的典型会话',
|
|
1007
|
+
outputK: Math.max(1, Math.round(calib.medianOutput / 1000)),
|
|
1008
|
+
inputK: Math.max(1, Math.round((calib.medianInput + calib.medianCacheRead + calib.medianCacheWrite) / 1000)),
|
|
1009
|
+
calibrated: true, calibrationCount: calib.count,
|
|
1010
|
+
};
|
|
1011
|
+
scenarios.unshift({
|
|
1012
|
+
id: sc.id, label: sc.label, outputK: sc.outputK, inputK: sc.inputK,
|
|
1013
|
+
calibrated: true, calibrationCount: sc.calibrationCount,
|
|
1014
|
+
optimistic: Math.floor(balance / scenarioCost(sc, 1.0, offpeakPrices)),
|
|
1015
|
+
pessimistic: Math.floor(balance / scenarioCost(sc, 0, peakPrices)),
|
|
1016
|
+
baseline: Math.floor(balance / scenarioCost(sc, 0.5, p)),
|
|
1017
|
+
offpeakBase: Math.floor(balance / scenarioCost(sc, 0.5, offpeakPrices)),
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return {
|
|
1023
|
+
currency: currency,
|
|
1024
|
+
balance: balance,
|
|
1025
|
+
conversion: conversion,
|
|
1026
|
+
scenarios: scenarios,
|
|
1027
|
+
calibration: calibrationFrom(sessionTotals(), CALIB_SESSIONS),
|
|
1028
|
+
pricing: pricing,
|
|
1029
|
+
fetchedAt: balances[config.activeProvider] ? balances[config.activeProvider].fetchedAt : null,
|
|
1030
|
+
stale: !!balances[config.activeProvider] && balances[config.activeProvider].error !== null && balances[config.activeProvider].data !== null,
|
|
1031
|
+
error: balances[config.activeProvider] ? balances[config.activeProvider].error : null,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ---------- 全部花费 ----------
|
|
1036
|
+
function totalSpend() {
|
|
1037
|
+
const cur = activeCurrency();
|
|
1038
|
+
let total = 0;
|
|
1039
|
+
for (let i = 0; i < usageRecords.length; i++) {
|
|
1040
|
+
const r = usageRecords[i];
|
|
1041
|
+
if (modelCurrency(r.model) !== cur) continue;
|
|
1042
|
+
const c = costOf(r, false);
|
|
1043
|
+
if (c != null) total += c;
|
|
1044
|
+
}
|
|
1045
|
+
return Math.round(total * 1000) / 1000;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ---------- 用量汇总 ----------
|
|
1049
|
+
function getUsageSummary(nowMs, sessionId) {
|
|
1050
|
+
const sessions = sessionTotals();
|
|
1051
|
+
return {
|
|
1052
|
+
sessions: sessions.length,
|
|
1053
|
+
calibration: calibrationFrom(sessions, CALIB_SESSIONS),
|
|
1054
|
+
currentSession: currentSessionSummary(sessions, sessionId),
|
|
1055
|
+
spend: spendSummary(nowMs),
|
|
1056
|
+
todaySpend: todaySpend(nowMs),
|
|
1057
|
+
monthSpend: monthSpend(nowMs),
|
|
1058
|
+
last30dSpend: last30dSpend(nowMs),
|
|
1059
|
+
totalSpend: totalSpend(),
|
|
1060
|
+
now: nowMs,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// ---------- 服务商列表 ----------
|
|
1065
|
+
function providerList(nowMs) {
|
|
1066
|
+
const out = [];
|
|
1067
|
+
for (const pid in PROVIDERS) {
|
|
1068
|
+
const prov = PROVIDERS[pid];
|
|
1069
|
+
const snap = balances[pid] || { data: null, fetchedAt: null, error: null };
|
|
1070
|
+
out.push({
|
|
1071
|
+
id: prov.id,
|
|
1072
|
+
displayName: prov.displayName,
|
|
1073
|
+
estimate: !!prov.estimate,
|
|
1074
|
+
active: pid === config.activeProvider,
|
|
1075
|
+
currency: snap.data ? snap.data.currency : (pid === 'deepseek' ? 'CNY' : 'USD'),
|
|
1076
|
+
total: snap.data ? snap.data.total : null,
|
|
1077
|
+
fetchedAt: snap.fetchedAt,
|
|
1078
|
+
error: snap.error,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
return out;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---------- 花费趋势 ----------
|
|
1085
|
+
function spendTrend(nowMs, days) {
|
|
1086
|
+
const d = days === 30 ? 30 : 7;
|
|
1087
|
+
const points = [];
|
|
1088
|
+
const byModel = {};
|
|
1089
|
+
for (let i = d - 1; i >= 0; i--) {
|
|
1090
|
+
const dayStart = new Date(nowMs + 8 * 3600 * 1000);
|
|
1091
|
+
dayStart.setUTCDate(dayStart.getUTCDate() - i);
|
|
1092
|
+
dayStart.setUTCHours(0, 0, 0, 0);
|
|
1093
|
+
const startMs = dayStart.getTime() - 8 * 3600 * 1000;
|
|
1094
|
+
const endMs = startMs + 86400 * 1000;
|
|
1095
|
+
const label = String(dayStart.getUTCMonth() + 1).padStart(2, '0') + '-' + String(dayStart.getUTCDate()).padStart(2, '0');
|
|
1096
|
+
let spend = 0;
|
|
1097
|
+
let offpeak = 0;
|
|
1098
|
+
for (let j = 0; j < usageRecords.length; j++) {
|
|
1099
|
+
const r = usageRecords[j];
|
|
1100
|
+
if (r.ts < startMs || r.ts >= endMs) continue;
|
|
1101
|
+
const c = costOf(r, false);
|
|
1102
|
+
if (c == null) continue;
|
|
1103
|
+
spend += c;
|
|
1104
|
+
const oc = costOf(r, true);
|
|
1105
|
+
if (oc != null) offpeak += oc;
|
|
1106
|
+
}
|
|
1107
|
+
points.push({ label: label, spend: Math.round(spend * 1000) / 1000, offpeak: Math.round(offpeak * 1000) / 1000 });
|
|
1108
|
+
}
|
|
1109
|
+
const cutoff = nowMs - d * 86400 * 1000;
|
|
1110
|
+
for (let j = 0; j < usageRecords.length; j++) {
|
|
1111
|
+
const r = usageRecords[j];
|
|
1112
|
+
if (r.ts < cutoff) continue;
|
|
1113
|
+
const c = costOf(r, false);
|
|
1114
|
+
if (c == null) continue;
|
|
1115
|
+
const key = r.model || r.provider;
|
|
1116
|
+
byModel[key] = (byModel[key] || 0) + c;
|
|
1117
|
+
}
|
|
1118
|
+
const byModelList = Object.keys(byModel).map(function (m) {
|
|
1119
|
+
return { model: m, spend: Math.round(byModel[m] * 1000) / 1000 };
|
|
1120
|
+
}).sort(function (a, b) { return b.spend - a.spend; });
|
|
1121
|
+
return { days: d, points: points, byModel: byModelList, now: nowMs };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ---------- RPC 路由(webServer HTTP,替代动态沙箱 harness.handle) ----------
|
|
1125
|
+
const ROUTE_PREFIX = '/_dsh/dsh-bottom-info-bar';
|
|
1126
|
+
const ROUTES = {
|
|
1127
|
+
getBalanceSnapshot: function (args) {
|
|
1128
|
+
const pid = args && typeof args === 'object' && args.provider ? String(args.provider) : '';
|
|
1129
|
+
return activeBalanceSummary(pid || undefined, Date.now());
|
|
1130
|
+
},
|
|
1131
|
+
getPricing: async function () {
|
|
1132
|
+
// M5:首次遇到未刷新过的 provider → 等待一次目录名拉取(llm 缺失则直接回退),
|
|
1133
|
+
// 保证模型名/服务商名与模型切换器一致;已刷新过则零等待直接读缓存
|
|
1134
|
+
const sel = modelSelection();
|
|
1135
|
+
const llm = ctx.get ? ctx.get('llm') : null;
|
|
1136
|
+
if (llm && !modelCatalogRefreshed[sel.provider]) await refreshModelCatalog(sel.provider);
|
|
1137
|
+
return computePricing(Date.now());
|
|
1138
|
+
},
|
|
1139
|
+
getEstimate: function () {
|
|
1140
|
+
return computeEstimate(Date.now());
|
|
1141
|
+
},
|
|
1142
|
+
getUsageSummary: function (args) {
|
|
1143
|
+
const sessionId = args && typeof args === 'object' ? String(args.sessionId || '') : '';
|
|
1144
|
+
return getUsageSummary(Date.now(), sessionId);
|
|
1145
|
+
},
|
|
1146
|
+
getProviders: function () {
|
|
1147
|
+
return { providers: providerList(Date.now()), activeProvider: config.activeProvider };
|
|
1148
|
+
},
|
|
1149
|
+
setActiveProvider: function (args) {
|
|
1150
|
+
const pid = args && typeof args === 'object' ? args.provider : null;
|
|
1151
|
+
if (pid && Object.hasOwn(PROVIDERS, pid)) {
|
|
1152
|
+
config.activeProvider = pid;
|
|
1153
|
+
refreshProviderBalance(pid);
|
|
1154
|
+
}
|
|
1155
|
+
return { activeProvider: config.activeProvider };
|
|
1156
|
+
},
|
|
1157
|
+
getSpendTrend: function (args) {
|
|
1158
|
+
const days = args && typeof args === 'object' ? Number(args.days) : 7;
|
|
1159
|
+
return spendTrend(Date.now(), days);
|
|
1160
|
+
},
|
|
1161
|
+
getConfig: function () {
|
|
1162
|
+
return { displayMode: config.displayMode, infoDensity: config.infoDensity, activeProvider: config.activeProvider, alertThreshold: config.alertThreshold, billingMode: config.billingMode };
|
|
1163
|
+
},
|
|
1164
|
+
getBillingMode: function () {
|
|
1165
|
+
// 纯本地计算(零网络开销;客户端 2s 高频轮询专用):返回 mode+provider+model,
|
|
1166
|
+
// 客户端据此检测模型/服务商切换并立即完整刷新信息栏
|
|
1167
|
+
const sel = modelSelection();
|
|
1168
|
+
return Object.assign(detectBillingMode(sel.provider, config.billingMode), { model: sel.model });
|
|
1169
|
+
},
|
|
1170
|
+
getSubscriptionSnapshot: function () {
|
|
1171
|
+
return getSubscriptionSnapshotRpc();
|
|
1172
|
+
},
|
|
1173
|
+
setDisplayMode: function (args) {
|
|
1174
|
+
const mode = args && typeof args === 'object' ? args.mode : null;
|
|
1175
|
+
if (mode === 'extend' || mode === 'replace') config.displayMode = mode;
|
|
1176
|
+
return { displayMode: config.displayMode };
|
|
1177
|
+
},
|
|
1178
|
+
setInfoDensity: function (args) {
|
|
1179
|
+
const d = args && typeof args === 'object' ? args.density : null;
|
|
1180
|
+
if (d === 'full' || d === 'compact') config.infoDensity = d;
|
|
1181
|
+
return { infoDensity: config.infoDensity };
|
|
1182
|
+
},
|
|
1183
|
+
};
|
|
1184
|
+
const MUTATING = { setActiveProvider: true, setDisplayMode: true, setInfoDensity: true, getSubscriptionSnapshot: true };
|
|
1185
|
+
|
|
1186
|
+
function sameOrigin(req) {
|
|
1187
|
+
const fetchSite = req.headers['sec-fetch-site'];
|
|
1188
|
+
if (fetchSite === 'cross-site') return false;
|
|
1189
|
+
const origin = req.headers.origin;
|
|
1190
|
+
if (origin === undefined) return fetchSite === 'same-origin' || fetchSite === 'same-site' || fetchSite === 'none';
|
|
1191
|
+
const host = req.headers.host;
|
|
1192
|
+
if (host === undefined) return false;
|
|
1193
|
+
try {
|
|
1194
|
+
const parsed = new URL(origin);
|
|
1195
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host;
|
|
1196
|
+
} catch {
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function readBody(req, maxBytes) {
|
|
1202
|
+
return new Promise(function (resolve, reject) {
|
|
1203
|
+
const chunks = [];
|
|
1204
|
+
let size = 0;
|
|
1205
|
+
req.on('data', function (chunk) {
|
|
1206
|
+
size += chunk.length;
|
|
1207
|
+
if (size > maxBytes) {
|
|
1208
|
+
const err = new Error('body too large');
|
|
1209
|
+
err.status = 413;
|
|
1210
|
+
reject(err);
|
|
1211
|
+
req.destroy();
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
chunks.push(chunk);
|
|
1215
|
+
});
|
|
1216
|
+
req.on('end', function () { resolve(Buffer.concat(chunks).toString('utf8')); });
|
|
1217
|
+
req.on('error', reject);
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function respond(res, status, payload) {
|
|
1222
|
+
const body = JSON.stringify(payload);
|
|
1223
|
+
res.writeHead(status, {
|
|
1224
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1225
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1226
|
+
'Cache-Control': 'no-store',
|
|
1227
|
+
});
|
|
1228
|
+
res.end(body);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
ctx.inject(['webServer'], function (webCtx) {
|
|
1232
|
+
webCtx.effect(function () {
|
|
1233
|
+
const dispose = webCtx.webServer.register({
|
|
1234
|
+
kind: 'prefix',
|
|
1235
|
+
path: ROUTE_PREFIX,
|
|
1236
|
+
handler: async function (req, res) {
|
|
1237
|
+
try {
|
|
1238
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
1239
|
+
const path = url.pathname;
|
|
1240
|
+
if (!path.startsWith(ROUTE_PREFIX + '/')) {
|
|
1241
|
+
respond(res, 404, { error: 'not found' });
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const method = decodeURIComponent(path.slice(ROUTE_PREFIX.length + 1));
|
|
1245
|
+
const fn = Object.hasOwn(ROUTES, method) ? ROUTES[method] : null;
|
|
1246
|
+
if (typeof fn !== 'function') {
|
|
1247
|
+
respond(res, 404, { error: 'unknown method: ' + method });
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (Object.hasOwn(MUTATING, method) && !sameOrigin(req)) {
|
|
1251
|
+
respond(res, 403, { error: 'cross-origin request rejected' });
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
let args = {};
|
|
1255
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
1256
|
+
const raw = await readBody(req, 64 * 1024);
|
|
1257
|
+
if (raw.length > 0) {
|
|
1258
|
+
try { args = JSON.parse(raw); } catch (e) { respond(res, 400, { error: 'invalid JSON body' }); return; }
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const result = await fn(args);
|
|
1262
|
+
respond(res, 200, result);
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
const status = (err && err.status) || 500;
|
|
1265
|
+
respond(res, status, { error: status === 500 ? 'internal error' : String((err && err.message) || err) });
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
return function () { dispose(); };
|
|
1270
|
+
}, 'dsh-bottom-info-bar: Web routes');
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
// ---------- 启动即刷 + 60s 定时刷新 ----------
|
|
1274
|
+
refreshAllBalances();
|
|
1275
|
+
refreshActiveSubscriptions(); // 惰性:无客户端请求过订阅源则不发起网络请求
|
|
1276
|
+
refreshActiveModelCatalog(); // M5:启动即拉一次 DSH 目录名(llm 缺失时静默回退,绝不崩溃)
|
|
1277
|
+
ctx.interval(refreshAllBalances, 60000);
|
|
1278
|
+
ctx.interval(refreshActiveSubscriptions, 60000);
|
|
1279
|
+
|
|
1280
|
+
// 卸载时冲刷未落盘的记账记录
|
|
1281
|
+
return function () {
|
|
1282
|
+
if (dirty) flushSave();
|
|
1283
|
+
};
|
|
1284
|
+
},
|
|
1285
|
+
};
|