@loommii/dsh-provider-usage 0.6.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/README.md +63 -0
- package/cordis.patch.yml +7 -0
- package/docs/CHANGELOG.md +120 -0
- package/lib/client.js +1164 -0
- package/lib/daily-stats.js +324 -0
- package/lib/index.js +1104 -0
- package/lib/secure-store.js +136 -0
- package/package.json +66 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
// dsh-provider-usage — Host half (M2 简易版,多供应商)。
|
|
2
|
+
// 两类适配器(对齐 cc-switch 的 UsageData 统一模型):
|
|
3
|
+
// usage-percent(订阅型):OpenCode Go {{baseUrl}}/v1/usage → rolling/weekly/monthly %
|
|
4
|
+
// balance-json(余额型) :DeepSeek {{baseUrl}}/user/balance → balance_infos[]
|
|
5
|
+
// 凭证链 per provider:DSH 设置 provider.apiKeyEnv → 凭证服务 → 环境变量。
|
|
6
|
+
// 路由:/api/provider-usage/opencode-go(兼容旧版)、/api/provider-usage/query?provider=<id>、
|
|
7
|
+
// /api/provider-usage/templates / credentials / dsh-providers / credential-refs(仅回环)。
|
|
8
|
+
// 本地 Token 统计(v0.5.0):/api/provider-usage/local-usage(累计+按模型汇总,按天物化存储),
|
|
9
|
+
// 数据源 = 自读 $DSH_HOME/sessions 会话文件(fzstd 解压,assistant/message 事件 data.usage),只读、不联网;
|
|
10
|
+
// 存储 = $DSH_HOME/provider-usage/daily-stats/YYYY-MM-DD.json:历史天封存(deps mtime 校验),只算今天。
|
|
11
|
+
// 缓存:每 provider 独立 30s 新鲜窗口 + 并发去重 + 3 快照环;stale-while-error。
|
|
12
|
+
|
|
13
|
+
import { join, dirname } from 'node:path'
|
|
14
|
+
import { stat as fsStat } from 'node:fs/promises'
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { createSecureStore, resolveProviderUsageDir } from './secure-store.js'
|
|
17
|
+
import * as daily from './daily-stats.js'
|
|
18
|
+
|
|
19
|
+
export const name = 'provider-usage'
|
|
20
|
+
|
|
21
|
+
/** 插件版本单一来源:package.json(UA 标识用;bundle 内嵌等读不到时回退字面量)。 */
|
|
22
|
+
const PLUGIN_VERSION = (() => {
|
|
23
|
+
try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.6.0' }
|
|
24
|
+
catch { return '0.6.0' }
|
|
25
|
+
})()
|
|
26
|
+
const USER_AGENT = 'dsh-provider-usage/' + PLUGIN_VERSION
|
|
27
|
+
|
|
28
|
+
/** 插件需要的运行时服务。本地统计不依赖 sessionQuery(自读会话文件)。 */
|
|
29
|
+
export const inject = ['webServer', 'settings', 'credentials']
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 15000
|
|
32
|
+
/** Command Code 次要端点(subscriptions)超时:失败只降级不致命,不拖累整体响应。 */
|
|
33
|
+
const SUBSCRIPTION_TIMEOUT_MS = 5000
|
|
34
|
+
const DEFAULT_PROVIDER_ID = 'opencode-go'
|
|
35
|
+
/** 缓存新鲜窗口:30s 内直接复用上次成功结果(per provider)。 */
|
|
36
|
+
const CACHE_FRESH_MS = 30000
|
|
37
|
+
/** 诊断快照环大小。 */
|
|
38
|
+
const SNAPSHOT_LIMIT = 3
|
|
39
|
+
/** 解析逻辑版本,改动加 1。 */
|
|
40
|
+
const PARSE_VERSION = 1
|
|
41
|
+
|
|
42
|
+
// ── 本地 Token 统计(DSH 会话日志,v0.5.0)──
|
|
43
|
+
const LOCAL_MAX_SESSIONS = 30
|
|
44
|
+
const LOCAL_PARSE_VERSION = 2
|
|
45
|
+
/** deps 校验周期:每 5 分钟重新校验一次历史天文件的依赖 mtime(compaction 改写兜底)。 */
|
|
46
|
+
const LOCAL_DEPS_CHECK_MS = 300000
|
|
47
|
+
|
|
48
|
+
/** 内置适配器注册表(URL 由插件自身维护,不读 DSH 内置目录 —— 2026-08-24 决策)。
|
|
49
|
+
* 用户可在客户端配置多个"供应商实例",每个实例绑定一个适配器 + 自定义名称 + key 引用。 */
|
|
50
|
+
const ADAPTERS = [
|
|
51
|
+
{
|
|
52
|
+
id: 'usage-percent',
|
|
53
|
+
displayName: 'OpenCode Go(订阅额度)',
|
|
54
|
+
baseUrl: 'https://opencode.ai/zen/go',
|
|
55
|
+
usagePath: '/v1/usage',
|
|
56
|
+
defaultCredentialRef: 'OPENCODE_GO_API_KEY',
|
|
57
|
+
settingsProviderKey: 'opencode-go',
|
|
58
|
+
description: 'OpenCode Go 订阅额度(5小时/7天/月度窗口百分比)',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'balance-json',
|
|
62
|
+
displayName: 'DeepSeek',
|
|
63
|
+
baseUrl: 'https://api.deepseek.com',
|
|
64
|
+
usagePath: '/user/balance',
|
|
65
|
+
defaultCredentialRef: 'DEEPSEEK_API_KEY',
|
|
66
|
+
settingsProviderKey: 'deepseek',
|
|
67
|
+
description: 'DeepSeek 开放平台账户余额(CNY,balance_infos)',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
// Command Code:DSH 非内置提供方(pi-ai 注册表无 commandcode),只允许「添加自定义提供方」
|
|
71
|
+
// (customOnly),Key 只存插件私有库(settingsProviderKey: null → 凭证链跳过 DSH 模型设置)。
|
|
72
|
+
id: 'commandcode-credits',
|
|
73
|
+
displayName: 'Command Code(订阅+余额)',
|
|
74
|
+
baseUrl: 'https://api.commandcode.ai',
|
|
75
|
+
usagePath: '/alpha/billing/credits',
|
|
76
|
+
subscriptionPath: '/alpha/billing/subscriptions',
|
|
77
|
+
defaultCredentialRef: 'COMMANDCODE_API_KEY',
|
|
78
|
+
settingsProviderKey: null,
|
|
79
|
+
customOnly: true,
|
|
80
|
+
description: 'Command Code 订阅使用量(5小时/周窗口)+ 月度剩余额度(USD)',
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
/** 旧 provider id → 适配器 id(向后兼容 /api/provider-usage/opencode-go 与 query?provider=…)。 */
|
|
85
|
+
const LEGACY_PROVIDER_MAP = { 'opencode-go': 'usage-percent', 'deepseek-balance': 'balance-json' }
|
|
86
|
+
|
|
87
|
+
/** Command Code 混合卡适配器(模块级常量,避免每次查询重复 find)。 */
|
|
88
|
+
const COMMANDCODE_ADAPTER = ADAPTERS.find((a) => a.id === 'commandcode-credits') || null
|
|
89
|
+
|
|
90
|
+
function isLoopbackRequest(req) {
|
|
91
|
+
const addr = req.socket && req.socket.remoteAddress
|
|
92
|
+
if (addr !== '127.0.0.1' && addr !== '::1' && addr !== '::ffff:127.0.0.1') return false
|
|
93
|
+
const host = req.headers && req.headers.host
|
|
94
|
+
if (host) {
|
|
95
|
+
const hostname = String(host).replace(/:[0-9]+$/, '').toLowerCase()
|
|
96
|
+
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '[::1]') return false
|
|
97
|
+
}
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 掩码 key:前 4 + … + 后 4。 */
|
|
102
|
+
function maskKey(key) {
|
|
103
|
+
if (typeof key !== 'string' || key.length === 0) return null
|
|
104
|
+
if (key.length <= 8) return '***'
|
|
105
|
+
return key.slice(0, 4) + '…' + key.slice(-4)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 解析指定 key 引用的 API Key:provider 配置 apiKeyEnv → DSH 凭证 → 环境变量。
|
|
109
|
+
* spec = { credentialRef, settingsProviderKey }。 */
|
|
110
|
+
/**
|
|
111
|
+
* 解析 Key 引用。
|
|
112
|
+
* - fromVault=true:手动实例显式来源=插件私有库 → 【直接】解密私有库,跳过 DSH 链(同名也不冲突)
|
|
113
|
+
* - fromVault=false:DSH 模型设置 → DSH 凭证 → 环境变量 → 私有库兜底(兼容旧实例)
|
|
114
|
+
*/
|
|
115
|
+
async function resolveCredentialFor(ctx, spec, storeFn, fromVault) {
|
|
116
|
+
let ref = spec.credentialRef
|
|
117
|
+
if (!fromVault) {
|
|
118
|
+
try {
|
|
119
|
+
let cfg = null
|
|
120
|
+
try { cfg = ctx.settings.get('llm-pi-ai') } catch { /* ignore */ }
|
|
121
|
+
if (!cfg) {
|
|
122
|
+
try { cfg = ctx.settings.get('settings.llm-pi-ai') } catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
const p = cfg && cfg.providers && cfg.providers[spec.settingsProviderKey]
|
|
125
|
+
if (p && typeof p.apiKeyEnv === 'string' && p.apiKeyEnv.trim() !== '') ref = p.apiKeyEnv.trim()
|
|
126
|
+
} catch { /* ignore */ }
|
|
127
|
+
try {
|
|
128
|
+
const creds = ctx.credentials || ctx.get('credentials')
|
|
129
|
+
if (creds && typeof creds.resolve === 'function') {
|
|
130
|
+
const hit = await creds.resolve(ref)
|
|
131
|
+
if (hit && hit.value) return { key: String(hit.value), source: 'credential:' + ref }
|
|
132
|
+
}
|
|
133
|
+
} catch { /* ignore */ }
|
|
134
|
+
try {
|
|
135
|
+
const env = typeof process !== 'undefined' ? process.env[ref] : undefined
|
|
136
|
+
if (env) return { key: env, source: 'env:' + ref }
|
|
137
|
+
} catch { /* ignore */ }
|
|
138
|
+
}
|
|
139
|
+
// 私有加密库:vault 直取,或作为旧链兜底
|
|
140
|
+
if (typeof storeFn === 'function') {
|
|
141
|
+
try {
|
|
142
|
+
const s = await storeFn()
|
|
143
|
+
const v = await s.get(ref)
|
|
144
|
+
if (v) return { key: v, source: 'provider-usage:' + ref }
|
|
145
|
+
} catch { /* ignore */ }
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 解析(订阅型:与 cc-switch extractor 语义一致)────────────────
|
|
151
|
+
|
|
152
|
+
function getUsedPercent(usage, name) {
|
|
153
|
+
const item = usage && usage[name]
|
|
154
|
+
if (!item || item.percent === undefined || item.percent === null) return null
|
|
155
|
+
const value = Number(item.percent)
|
|
156
|
+
if (Number.isNaN(value)) return null
|
|
157
|
+
return Math.max(0, Math.min(100, value))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatRemainingPercent(usedPercent) {
|
|
161
|
+
if (usedPercent === null) return '--'
|
|
162
|
+
return Math.round(100 - usedPercent) + '%'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatCountdown(isoTime) {
|
|
166
|
+
if (!isoTime) return '--'
|
|
167
|
+
const resetTimestamp = Date.parse(isoTime)
|
|
168
|
+
if (Number.isNaN(resetTimestamp)) return '--'
|
|
169
|
+
const seconds = Math.max(0, Math.floor((resetTimestamp - Date.now()) / 1000))
|
|
170
|
+
if (seconds <= 0) return '已到期'
|
|
171
|
+
const days = Math.floor(seconds / 86400)
|
|
172
|
+
let rest = seconds % 86400
|
|
173
|
+
const hours = Math.floor(rest / 3600)
|
|
174
|
+
rest = rest % 3600
|
|
175
|
+
const minutes = Math.floor(rest / 60)
|
|
176
|
+
if (days > 0) return days + 'd' + hours + 'h'
|
|
177
|
+
if (hours > 0) return hours + 'h' + minutes + 'm'
|
|
178
|
+
if (minutes > 0) return minutes + 'm'
|
|
179
|
+
return Math.floor(rest) + 's'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 把官方响应换算成结构化结果(含用户脚本同款 extra 文案)。 */
|
|
183
|
+
function parseUsage(body) {
|
|
184
|
+
let data = body
|
|
185
|
+
if (typeof data === 'string') {
|
|
186
|
+
try { data = JSON.parse(data) } catch { return { error: 'bad-json' } }
|
|
187
|
+
}
|
|
188
|
+
const usage = data && data.usage
|
|
189
|
+
if (!usage) return { error: 'no-usage' }
|
|
190
|
+
|
|
191
|
+
const rollingUsed = getUsedPercent(usage, 'rolling')
|
|
192
|
+
const weeklyUsed = getUsedPercent(usage, 'weekly')
|
|
193
|
+
const monthlyUsed = getUsedPercent(usage, 'monthly')
|
|
194
|
+
const monthlyRemaining = monthlyUsed === null ? 0 : 100 - monthlyUsed
|
|
195
|
+
|
|
196
|
+
const windowOf = (name, usedPct) => {
|
|
197
|
+
const item = usage[name] || {}
|
|
198
|
+
return {
|
|
199
|
+
status: typeof item.status === 'string' ? item.status : 'ok',
|
|
200
|
+
usedPct,
|
|
201
|
+
remainingPct: usedPct === null ? null : Math.round((100 - usedPct) * 100) / 100,
|
|
202
|
+
resetsAt: typeof item.resetsAt === 'string' && item.resetsAt ? item.resetsAt : null,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
planName: 'OpenCode Go',
|
|
208
|
+
remaining: monthlyRemaining,
|
|
209
|
+
unit: '%',
|
|
210
|
+
extra:
|
|
211
|
+
'5小时: ' + formatRemainingPercent(rollingUsed) +
|
|
212
|
+
' 7天: ' + formatRemainingPercent(weeklyUsed) +
|
|
213
|
+
' ◷ ' + formatCountdown(usage.monthly && usage.monthly.resetsAt) +
|
|
214
|
+
' 5小时重置 ' + formatCountdown(usage.rolling && usage.rolling.resetsAt) +
|
|
215
|
+
' · 7天重置 ' + formatCountdown(usage.weekly && usage.weekly.resetsAt),
|
|
216
|
+
windows: {
|
|
217
|
+
rolling: windowOf('rolling', rollingUsed),
|
|
218
|
+
weekly: windowOf('weekly', weeklyUsed),
|
|
219
|
+
monthly: windowOf('monthly', monthlyUsed),
|
|
220
|
+
},
|
|
221
|
+
cards: null,
|
|
222
|
+
isValid: true,
|
|
223
|
+
invalidMessage: null,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── 解析(余额型:DeepSeek /user/balance,官方文档 + cc-switch balance.rs 语义)──
|
|
228
|
+
|
|
229
|
+
/** 把 DeepSeek balance 响应换算成结构化结果(每币种一张卡)。 */
|
|
230
|
+
function parseBalance(body) {
|
|
231
|
+
let data = body
|
|
232
|
+
if (typeof data === 'string') {
|
|
233
|
+
try { data = JSON.parse(data) } catch { return { error: 'bad-json' } }
|
|
234
|
+
}
|
|
235
|
+
const isAvailable = data && typeof data.is_available === 'boolean'
|
|
236
|
+
? data.is_available
|
|
237
|
+
: true
|
|
238
|
+
const infos = data && Array.isArray(data.balance_infos) ? data.balance_infos : null
|
|
239
|
+
if (!infos) return { error: 'no-balance' }
|
|
240
|
+
|
|
241
|
+
const cards = infos.map((info) => {
|
|
242
|
+
const currency = typeof info.currency === 'string' && info.currency ? info.currency : 'CNY'
|
|
243
|
+
const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
|
|
244
|
+
return {
|
|
245
|
+
currency,
|
|
246
|
+
remaining: toNum(info.total_balance),
|
|
247
|
+
granted: toNum(info.granted_balance),
|
|
248
|
+
toppedUp: toNum(info.topped_up_balance),
|
|
249
|
+
isValid: isAvailable,
|
|
250
|
+
invalidMessage: isAvailable ? null : '余额不足(账户不可用)',
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const fmt = (n) => (n === null ? '--' : n.toFixed(2))
|
|
255
|
+
const parts = cards.map((c) => {
|
|
256
|
+
let s = c.currency + ' ¥' + fmt(c.remaining)
|
|
257
|
+
if (c.granted !== null || c.toppedUp !== null) {
|
|
258
|
+
s += '(赠送 ' + fmt(c.granted) + ' + 充值 ' + fmt(c.toppedUp) + ')'
|
|
259
|
+
}
|
|
260
|
+
return s
|
|
261
|
+
})
|
|
262
|
+
const extra = parts.join(';') + (isAvailable ? '' : ' · 账户不可用(余额不足)')
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
planName: 'DeepSeek 余额',
|
|
266
|
+
remaining: cards.length > 0 ? cards[0].remaining : null,
|
|
267
|
+
unit: cards.length > 0 ? cards[0].currency : 'CNY',
|
|
268
|
+
extra,
|
|
269
|
+
windows: null,
|
|
270
|
+
cards,
|
|
271
|
+
isValid: isAvailable,
|
|
272
|
+
invalidMessage: isAvailable ? null : '余额不足(账户不可用)',
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── 解析(Command Code:订阅使用量 + 月度剩余额度混合卡)──────────────
|
|
277
|
+
// 官方 API(api.commandcode.ai):
|
|
278
|
+
// GET /alpha/billing/credits → credits.monthlyCredits(月度剩余 $)+ windowLimits.{fiveHour,weekly}.{used,cap,resetAt}
|
|
279
|
+
// GET /alpha/billing/subscriptions → data.{planId,currentPeriodEnd,status}
|
|
280
|
+
// 官方【不提供月用量窗口】,月用量 = 计划总额 − monthlyCredits(调研方案 A,Rainytoken 同款减法)。
|
|
281
|
+
// 计划总额依赖 planId → 映射表(官方新增计划需同步更新;未知计划安全降级为仅窗口+剩余)。
|
|
282
|
+
|
|
283
|
+
/** Command Code 订阅计划 → 月度额度(USD)与展示名(调研文档 §3,2026-08 实测)。 */
|
|
284
|
+
const COMMANDCODE_PLANS = {
|
|
285
|
+
'individual-go': { quota: 10, name: 'Go' },
|
|
286
|
+
'individual-goat': { quota: 70, name: 'GOAT' },
|
|
287
|
+
'individual-pro': { quota: 80, name: 'Pro' },
|
|
288
|
+
'individual-max': { quota: 150, name: 'Max' },
|
|
289
|
+
'individual-ultra': { quota: 300, name: 'Ultra' },
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const COMMANDCODE_PLAN_FALLBACK_NAME = 'Command Code'
|
|
293
|
+
/** 月已用百分比钳制到 [0,100];无法推算(无总额/无剩余)返回 null。 */
|
|
294
|
+
function commandcodeMonthlyUsedPct(totalQuota, monthlyCredits) {
|
|
295
|
+
if (totalQuota === null || totalQuota === undefined || !(totalQuota > 0)) return null
|
|
296
|
+
const remaining = Number(monthlyCredits)
|
|
297
|
+
if (!Number.isFinite(remaining)) return null
|
|
298
|
+
return Math.max(0, Math.min(100, ((totalQuota - remaining) / totalQuota) * 100))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 把 credits + subscriptions 两个端点的响应换算成混合模型(订阅使用量 + 剩余额度)。 */
|
|
302
|
+
function parseCommandCodeCredits(creditsBody, subBody) {
|
|
303
|
+
let credits = creditsBody
|
|
304
|
+
if (typeof credits === 'string') { try { credits = JSON.parse(credits) } catch { return { error: 'bad-json' } } }
|
|
305
|
+
const creditsData = credits && credits.credits
|
|
306
|
+
const limits = credits && credits.windowLimits
|
|
307
|
+
if (creditsData === undefined || creditsData === null || limits === undefined || limits === null) {
|
|
308
|
+
return { error: 'no-credits' }
|
|
309
|
+
}
|
|
310
|
+
const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
|
|
311
|
+
const monthlyCredits = toNum(creditsData.monthlyCredits)
|
|
312
|
+
const purchasedCredits = toNum(creditsData.purchasedCredits)
|
|
313
|
+
const freeCredits = toNum(creditsData.freeCredits)
|
|
314
|
+
const fiveHour = limits.fiveHour || {}
|
|
315
|
+
const weekly = limits.weekly || {}
|
|
316
|
+
const fiveHourCap = toNum(fiveHour.cap)
|
|
317
|
+
const weeklyCap = toNum(weekly.cap)
|
|
318
|
+
const fiveHourUsed = toNum(fiveHour.used)
|
|
319
|
+
const weeklyUsed = toNum(weekly.used)
|
|
320
|
+
const fiveHourResetAt = typeof fiveHour.resetAt === 'number' && Number.isFinite(fiveHour.resetAt)
|
|
321
|
+
? new Date(fiveHour.resetAt).toISOString() : null
|
|
322
|
+
const weeklyResetAt = typeof weekly.resetAt === 'number' && Number.isFinite(weekly.resetAt)
|
|
323
|
+
? new Date(weekly.resetAt).toISOString() : null
|
|
324
|
+
|
|
325
|
+
// 订阅端点:planId → 计划总额/名称;失败或未知计划安全降级
|
|
326
|
+
let sub = subBody
|
|
327
|
+
if (typeof sub === 'string') { try { sub = JSON.parse(sub) } catch { sub = null } }
|
|
328
|
+
const subData = sub && sub.success !== false && sub.data ? sub.data : null
|
|
329
|
+
const planId = subData && typeof subData.planId === 'string' ? subData.planId : null
|
|
330
|
+
const plan = planId ? COMMANDCODE_PLANS[planId] : null
|
|
331
|
+
const totalQuota = plan ? plan.quota : null
|
|
332
|
+
const planName = plan ? COMMANDCODE_PLAN_FALLBACK_NAME + '(' + plan.name + ')' : COMMANDCODE_PLAN_FALLBACK_NAME
|
|
333
|
+
const nextResetAt = subData && typeof subData.currentPeriodEnd === 'string' && subData.currentPeriodEnd
|
|
334
|
+
? subData.currentPeriodEnd : null
|
|
335
|
+
|
|
336
|
+
const monthlyUsedPct = commandcodeMonthlyUsedPct(totalQuota, monthlyCredits)
|
|
337
|
+
const monthlyUsed = (monthlyUsedPct === null || totalQuota === null) ? null
|
|
338
|
+
: Math.max(0, totalQuota - monthlyCredits)
|
|
339
|
+
|
|
340
|
+
// resetsAt 命名对齐 parseUsage(client WindowRow 统一读 win.resetsAt)
|
|
341
|
+
const windowOf = (used, cap, resetAt) => {
|
|
342
|
+
const pct = (used !== null && cap !== null && cap > 0) ? Math.max(0, Math.min(100, (used / cap) * 100)) : null
|
|
343
|
+
return { usedPct: pct, used, cap, resetsAt: resetAt }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const extraParts = []
|
|
347
|
+
if (totalQuota !== null && monthlyUsed !== null) {
|
|
348
|
+
extraParts.push('月已用 ' + monthlyUsed.toFixed(2) + ' / ' + totalQuota.toFixed(2) + ' USD')
|
|
349
|
+
}
|
|
350
|
+
if (monthlyCredits !== null) extraParts.push('月剩余 ' + monthlyCredits.toFixed(2) + ' USD')
|
|
351
|
+
if (purchasedCredits !== null && purchasedCredits > 0) extraParts.push('付费包 ' + purchasedCredits.toFixed(2) + ' USD')
|
|
352
|
+
if (freeCredits !== null && freeCredits > 0) extraParts.push('赠送 ' + freeCredits.toFixed(2) + ' USD')
|
|
353
|
+
if (fiveHourResetAt) extraParts.push('5小时重置 ' + formatCountdown(fiveHourResetAt))
|
|
354
|
+
if (weeklyResetAt) extraParts.push('周重置 ' + formatCountdown(weeklyResetAt))
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
planName,
|
|
358
|
+
remaining: monthlyCredits,
|
|
359
|
+
unit: 'USD',
|
|
360
|
+
used: monthlyUsed,
|
|
361
|
+
totalQuota,
|
|
362
|
+
// 顶层透传字段(对外协议,v0.6.0 起保留):月度周期重置时间 = currentPeriodEnd。
|
|
363
|
+
// 与 monthly.resetAt 同源;顶层供调试/外部脚本读取,卡片月窗口行读 monthly.resetAt。
|
|
364
|
+
nextResetAt,
|
|
365
|
+
extra: extraParts.join(';'),
|
|
366
|
+
windows: {
|
|
367
|
+
fiveHour: windowOf(fiveHourUsed, fiveHourCap, fiveHourResetAt),
|
|
368
|
+
weekly: windowOf(weeklyUsed, weeklyCap, weeklyResetAt),
|
|
369
|
+
},
|
|
370
|
+
monthly: { usedPct: monthlyUsedPct, used: monthlyUsed, totalQuota, remaining: monthlyCredits, resetsAt: nextResetAt },
|
|
371
|
+
cards: null,
|
|
372
|
+
isValid: true,
|
|
373
|
+
invalidMessage: null,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── 插件主体 ────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export function apply(ctx, rawConfig) {
|
|
380
|
+
const config = {
|
|
381
|
+
baseUrlOverride: rawConfig && typeof rawConfig.baseUrl === 'string' && rawConfig.baseUrl
|
|
382
|
+
? rawConfig.baseUrl.replace(/\/$/, '')
|
|
383
|
+
: null,
|
|
384
|
+
timeoutMs: rawConfig && typeof rawConfig.timeoutMs === 'number' && rawConfig.timeoutMs > 0
|
|
385
|
+
? Math.min(rawConfig.timeoutMs, 30000)
|
|
386
|
+
: DEFAULT_TIMEOUT_MS,
|
|
387
|
+
// Command Code 次要端点超时(可覆盖:慢网络用户可调大;测试可调小)
|
|
388
|
+
subscriptionTimeoutMs: rawConfig && typeof rawConfig.subscriptionTimeoutMs === 'number' && rawConfig.subscriptionTimeoutMs > 0
|
|
389
|
+
? Math.min(rawConfig.subscriptionTimeoutMs, 30000)
|
|
390
|
+
: SUBSCRIPTION_TIMEOUT_MS,
|
|
391
|
+
maxSessions: rawConfig && typeof rawConfig.maxSessions === 'number' && rawConfig.maxSessions > 0
|
|
392
|
+
? Math.min(rawConfig.maxSessions, 100)
|
|
393
|
+
: LOCAL_MAX_SESSIONS,
|
|
394
|
+
// 本地 Token 统计:会话目录默认 $DSH_HOME/sessions(可覆盖);天文件存 $DSH_HOME/provider-usage/daily-stats/
|
|
395
|
+
sessionsDir: rawConfig && typeof rawConfig.sessionsDir === 'string' && rawConfig.sessionsDir
|
|
396
|
+
? rawConfig.sessionsDir
|
|
397
|
+
: join(dirname(resolveProviderUsageDir()), 'sessions'),
|
|
398
|
+
dailyDir: join(resolveProviderUsageDir(), 'daily-stats'),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// cordis 配置的 baseUrl 仅覆盖 opencode-go(历史语义),其余 preset 用内置 URL(插件自维护)
|
|
402
|
+
const effectiveBaseUrl = (cfg) =>
|
|
403
|
+
cfg.id === 'usage-percent' && config.baseUrlOverride ? config.baseUrlOverride : cfg.baseUrl
|
|
404
|
+
|
|
405
|
+
// 私有加密凭证库(方案 B,懒初始化:首次使用时生成密钥)
|
|
406
|
+
let secure = null
|
|
407
|
+
async function secureStore() {
|
|
408
|
+
if (!secure) { secure = createSecureStore(resolveProviderUsageDir()); await secure.init() }
|
|
409
|
+
return secure
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 某 Key 引用位于哪个库:'dsh' | 'vault' | 'both' | null(供客户端区分导入/手动实例,旧数据自愈)。 */
|
|
413
|
+
async function refStoreOf(name) {
|
|
414
|
+
let dshHit = false
|
|
415
|
+
const creds = ctx.credentials || ctx.get('credentials')
|
|
416
|
+
if (creds) {
|
|
417
|
+
try {
|
|
418
|
+
if (typeof creds.describe === 'function') {
|
|
419
|
+
const d = await creds.describe(name)
|
|
420
|
+
if (d && (d.source || d.value !== undefined || d.inherited !== undefined || d.status === 'configured')) dshHit = true
|
|
421
|
+
} else if (typeof creds.resolve === 'function') {
|
|
422
|
+
const r = await creds.resolve(name)
|
|
423
|
+
if (r && r.value !== undefined && r.value !== null) dshHit = true
|
|
424
|
+
}
|
|
425
|
+
} catch (e) { /* ignore */ }
|
|
426
|
+
}
|
|
427
|
+
let vaultHit = false
|
|
428
|
+
try { vaultHit = await (await secureStore()).has(name) } catch (e) { /* ignore */ }
|
|
429
|
+
if (dshHit && vaultHit) return 'both'
|
|
430
|
+
if (dshHit) return 'dsh'
|
|
431
|
+
if (vaultHit) return 'vault'
|
|
432
|
+
return null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** 某 Key 引用是否已配置:DSH describe/resolve → 私有加密库。 */
|
|
436
|
+
async function refConfigured(name) {
|
|
437
|
+
return (await refStoreOf(name)) !== null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// per-adapter 状态:adapterId -> { lastGood, fetching }
|
|
441
|
+
const states = new Map()
|
|
442
|
+
function stateOf(adapterId) {
|
|
443
|
+
let s = states.get(adapterId)
|
|
444
|
+
if (!s) { s = { lastGood: null, fetching: null }; states.set(adapterId, s) }
|
|
445
|
+
return s
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 天文件内容缓存:轮询不再每次 readFile+parse 历史天(recompute/写入时刷新)
|
|
449
|
+
const dayCache = new Map()
|
|
450
|
+
async function cachedDay(day) {
|
|
451
|
+
let d = dayCache.get(day)
|
|
452
|
+
if (d === undefined) {
|
|
453
|
+
d = await daily.readDayFile(config.dailyDir, day)
|
|
454
|
+
if (d) dayCache.set(day, d)
|
|
455
|
+
if (dayCache.size > 2000) dayCache.clear()
|
|
456
|
+
}
|
|
457
|
+
return d || null
|
|
458
|
+
}
|
|
459
|
+
function rememberDay(day, data) {
|
|
460
|
+
dayCache.set(day, data)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function recordSnapshot(adapterId, snapshot) {
|
|
464
|
+
const s = stateOf(adapterId)
|
|
465
|
+
s.snapshots = s.snapshots || []
|
|
466
|
+
s.snapshots.push(snapshot)
|
|
467
|
+
if (s.snapshots.length > SNAPSHOT_LIMIT) s.snapshots = s.snapshots.slice(-SNAPSHOT_LIMIT)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const okResponse = (cfg, value, state, extraFields) => ({
|
|
471
|
+
ok: true,
|
|
472
|
+
providerId: cfg.id,
|
|
473
|
+
displayName: cfg.displayName,
|
|
474
|
+
config: { baseUrl: effectiveBaseUrl(cfg) + cfg.usagePath, timeoutMs: config.timeoutMs, provider: cfg.id },
|
|
475
|
+
valid: true,
|
|
476
|
+
planName: value.planName,
|
|
477
|
+
remaining: value.remaining,
|
|
478
|
+
unit: value.unit,
|
|
479
|
+
used: value.used,
|
|
480
|
+
totalQuota: value.totalQuota,
|
|
481
|
+
nextResetAt: value.nextResetAt,
|
|
482
|
+
monthly: value.monthly,
|
|
483
|
+
extra: value.extra,
|
|
484
|
+
windows: value.windows,
|
|
485
|
+
cards: value.cards,
|
|
486
|
+
isValid: value.isValid !== false,
|
|
487
|
+
invalidMessage: value.invalidMessage || null,
|
|
488
|
+
fetchedAt: state.lastGood ? state.lastGood.queriedAt : Date.now(),
|
|
489
|
+
cached: false,
|
|
490
|
+
stale: false,
|
|
491
|
+
error: null,
|
|
492
|
+
credential: state.lastGood ? state.lastGood.credential : null,
|
|
493
|
+
snapshots: (state.snapshots || []).slice(),
|
|
494
|
+
parseVersion: PARSE_VERSION,
|
|
495
|
+
...extraFields,
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
/** 对单个 GET 端点发起请求;返回 { status, ok, body, error }(error 为 null 表示成功)。
|
|
499
|
+
* timeoutMs 可选:次要端点用短超时,避免拖累整体响应。 */
|
|
500
|
+
async function fetchEndpoint(cfg, resolved, path, timeoutMs) {
|
|
501
|
+
const attemptAt = Date.now()
|
|
502
|
+
const url = effectiveBaseUrl(cfg) + path
|
|
503
|
+
const controller = new AbortController()
|
|
504
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs || config.timeoutMs)
|
|
505
|
+
let res
|
|
506
|
+
try {
|
|
507
|
+
res = await fetch(url, {
|
|
508
|
+
method: 'GET',
|
|
509
|
+
headers: {
|
|
510
|
+
authorization: 'Bearer ' + resolved.key,
|
|
511
|
+
accept: 'application/json',
|
|
512
|
+
'user-agent': USER_AGENT,
|
|
513
|
+
},
|
|
514
|
+
signal: controller.signal,
|
|
515
|
+
})
|
|
516
|
+
} catch (e) {
|
|
517
|
+
clearTimeout(timer)
|
|
518
|
+
const aborted = e && (e.name === 'AbortError' || e.message === 'AbortError')
|
|
519
|
+
return { attemptAt, status: null, ok: false, body: null, error: aborted ? 'timeout' : 'network' }
|
|
520
|
+
}
|
|
521
|
+
clearTimeout(timer)
|
|
522
|
+
if (res.status === 401 || res.status === 403) {
|
|
523
|
+
return { attemptAt, status: res.status, ok: false, body: null, error: 'unauthorized' }
|
|
524
|
+
}
|
|
525
|
+
if (!res.ok) {
|
|
526
|
+
return { attemptAt, status: res.status, ok: false, body: null, error: 'http-' + res.status }
|
|
527
|
+
}
|
|
528
|
+
let body = null
|
|
529
|
+
try { body = await res.json() } catch { /* ignore */ }
|
|
530
|
+
if (body === null) return { attemptAt, status: res.status, ok: false, body: null, error: 'bad-json' }
|
|
531
|
+
return { attemptAt, status: res.status, ok: true, body, error: null }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function doQuery(cfg, credentialRef, fromVault) {
|
|
535
|
+
const ref = (typeof credentialRef === 'string' && credentialRef.trim() !== '') ? credentialRef.trim() : cfg.defaultCredentialRef
|
|
536
|
+
const key = cfg.id + '|' + ref + '|' + (fromVault ? 'vault' : 'dsh')
|
|
537
|
+
const state = stateOf(key)
|
|
538
|
+
const resolved = await resolveCredentialFor(ctx, { credentialRef: ref, settingsProviderKey: cfg.settingsProviderKey }, secureStore, fromVault === true)
|
|
539
|
+
if (!resolved) {
|
|
540
|
+
recordSnapshot(key, { attemptAt: Date.now(), httpStatus: null, error: 'no-api-key' })
|
|
541
|
+
return fail(cfg, state, 'no-api-key', '未找到 ' + cfg.displayName + ' API Key:请在 DSH 模型设置中配置,或设置环境变量 ' + ref)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const credential = { source: resolved.source, keyHint: maskKey(resolved.key) }
|
|
545
|
+
|
|
546
|
+
// Command Code:并行查两个端点(credits 必查、subscriptions 可选);其余适配器单端点。
|
|
547
|
+
// 主端点失败 → 走统一失败路径(stale 保留旧值);订阅端点失败 → 降级展示(窗口+剩余仍在)。
|
|
548
|
+
if (COMMANDCODE_ADAPTER && cfg.id === COMMANDCODE_ADAPTER.id) {
|
|
549
|
+
const [creditsRes, subRes] = await Promise.all([
|
|
550
|
+
fetchEndpoint(cfg, resolved, cfg.usagePath),
|
|
551
|
+
// subscriptions 是次要端点:短超时,credits 成功而它卡死时整体不被拖到全局超时
|
|
552
|
+
fetchEndpoint(cfg, resolved, cfg.subscriptionPath, config.subscriptionTimeoutMs),
|
|
553
|
+
])
|
|
554
|
+
recordSnapshot(key, { attemptAt: creditsRes.attemptAt, httpStatus: creditsRes.status, error: creditsRes.error })
|
|
555
|
+
if (!creditsRes.ok) {
|
|
556
|
+
const msg = creditsRes.error === 'unauthorized'
|
|
557
|
+
? 'API Key 无效或已过期(401/403)'
|
|
558
|
+
: creditsRes.error === 'timeout'
|
|
559
|
+
? '请求超时(' + config.timeoutMs + 'ms)'
|
|
560
|
+
: creditsRes.error === 'network'
|
|
561
|
+
? '网络请求失败'
|
|
562
|
+
: creditsRes.error === 'bad-json'
|
|
563
|
+
? '接口响应不是有效 JSON'
|
|
564
|
+
: '接口返回 HTTP ' + creditsRes.status
|
|
565
|
+
if (creditsRes.error === 'unauthorized') return fail(cfg, state, 'unauthorized', msg, creditsRes.status)
|
|
566
|
+
if (creditsRes.error === 'timeout' || creditsRes.error === 'network') return failTransient(cfg, state, creditsRes.error, msg)
|
|
567
|
+
return fail(cfg, state, creditsRes.error === 'bad-json' ? 'parse' : 'http', msg, creditsRes.status)
|
|
568
|
+
}
|
|
569
|
+
let parsed = parseCommandCodeCredits(creditsRes.body, subRes.ok ? subRes.body : null)
|
|
570
|
+
if (parsed.error === 'no-credits') {
|
|
571
|
+
recordSnapshot(key, { attemptAt: creditsRes.attemptAt, httpStatus: creditsRes.status, error: 'no-credits' })
|
|
572
|
+
return fail(cfg, state, 'parse', '没有找到 Command Code 额度数据(credits/windowLimits)', creditsRes.status)
|
|
573
|
+
}
|
|
574
|
+
if (parsed.error === 'bad-json') {
|
|
575
|
+
recordSnapshot(key, { attemptAt: creditsRes.attemptAt, httpStatus: creditsRes.status, error: 'bad-json' })
|
|
576
|
+
return fail(cfg, state, 'parse', '接口返回的数据不是有效 JSON', creditsRes.status)
|
|
577
|
+
}
|
|
578
|
+
const good = { queriedAt: Date.now(), credential, parsed }
|
|
579
|
+
state.lastGood = good
|
|
580
|
+
return okResponse(cfg, parsed, state, { fetchedAt: good.queriedAt, credential })
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const attemptAt = Date.now()
|
|
584
|
+
const url = effectiveBaseUrl(cfg) + cfg.usagePath
|
|
585
|
+
const controller = new AbortController()
|
|
586
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
|
587
|
+
let res
|
|
588
|
+
try {
|
|
589
|
+
res = await fetch(url, {
|
|
590
|
+
method: 'GET',
|
|
591
|
+
headers: {
|
|
592
|
+
authorization: 'Bearer ' + resolved.key,
|
|
593
|
+
accept: 'application/json',
|
|
594
|
+
'user-agent': USER_AGENT,
|
|
595
|
+
},
|
|
596
|
+
signal: controller.signal,
|
|
597
|
+
})
|
|
598
|
+
} catch (e) {
|
|
599
|
+
clearTimeout(timer)
|
|
600
|
+
const aborted = e && (e.name === 'AbortError' || e.message === 'AbortError')
|
|
601
|
+
recordSnapshot(key, { attemptAt, httpStatus: null, error: aborted ? 'timeout' : 'network' })
|
|
602
|
+
return aborted ? failTransient(cfg, state, 'timeout', '请求超时(' + config.timeoutMs + 'ms)') : failTransient(cfg, state, 'network', '网络请求失败:' + String((e && e.message) || e))
|
|
603
|
+
}
|
|
604
|
+
clearTimeout(timer)
|
|
605
|
+
|
|
606
|
+
if (res.status === 401 || res.status === 403) {
|
|
607
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'unauthorized' })
|
|
608
|
+
return fail(cfg, state, 'unauthorized', res.status === 401 ? 'API Key 无效或已过期(401)' : '无访问权限(403)', res.status)
|
|
609
|
+
}
|
|
610
|
+
if (!res.ok) {
|
|
611
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'http-' + res.status })
|
|
612
|
+
return fail(cfg, state, 'http', '接口返回 HTTP ' + res.status, res.status)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let body = null
|
|
616
|
+
try { body = await res.json() } catch { /* ignore */ }
|
|
617
|
+
if (body === null) {
|
|
618
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'bad-json' })
|
|
619
|
+
return fail(cfg, state, 'parse', '接口响应不是有效 JSON', res.status)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const parsed = cfg.id === 'balance-json' ? parseBalance(body) : parseUsage(body)
|
|
623
|
+
if (parsed.error === 'no-usage') {
|
|
624
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'no-usage' })
|
|
625
|
+
return fail(cfg, state, 'parse', '没有找到 OpenCode Go 用量数据', res.status)
|
|
626
|
+
}
|
|
627
|
+
if (parsed.error === 'no-balance') {
|
|
628
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'no-balance' })
|
|
629
|
+
return fail(cfg, state, 'parse', '没有找到 DeepSeek 余额数据(balance_infos)', res.status)
|
|
630
|
+
}
|
|
631
|
+
if (parsed.error === 'bad-json') {
|
|
632
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: 'bad-json' })
|
|
633
|
+
return fail(cfg, state, 'parse', '接口返回的数据不是有效 JSON', res.status)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
recordSnapshot(key, { attemptAt, httpStatus: res.status, error: null })
|
|
637
|
+
const good = { queriedAt: Date.now(), credential, parsed }
|
|
638
|
+
state.lastGood = good
|
|
639
|
+
return okResponse(cfg, parsed, state, { fetchedAt: good.queriedAt, credential })
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function fail(cfg, state, type, message, httpStatus) {
|
|
643
|
+
if (state.lastGood) {
|
|
644
|
+
const prev = state.lastGood
|
|
645
|
+
return { ...okResponse(cfg, prev.parsed, state, { fetchedAt: prev.queriedAt, credential: prev.credential }), cached: true, stale: true, ok: false, error: { type, message, httpStatus: httpStatus || null } }
|
|
646
|
+
}
|
|
647
|
+
return { ok: false, providerId: cfg.id, displayName: cfg.displayName, valid: null, planName: null, remaining: null, unit: '%', used: null, totalQuota: null, nextResetAt: null, monthly: null, extra: null, windows: null, cards: null, isValid: false, invalidMessage: null, fetchedAt: null, cached: false, stale: false, error: { type, message, httpStatus: httpStatus || null }, credential: null, snapshots: (state.snapshots || []).slice(), parseVersion: PARSE_VERSION, config: { baseUrl: effectiveBaseUrl(cfg) + cfg.usagePath, timeoutMs: config.timeoutMs, provider: cfg.id } }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function failTransient(cfg, state, type, message) {
|
|
651
|
+
if (state.lastGood) {
|
|
652
|
+
const prev = state.lastGood
|
|
653
|
+
return { ...okResponse(cfg, prev.parsed, state, { fetchedAt: prev.queriedAt, credential: prev.credential }), cached: true, stale: true, ok: false, error: { type, message, httpStatus: null } }
|
|
654
|
+
}
|
|
655
|
+
return fail(cfg, state, type, message, null)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 查询参数:adapter(新)/ provider(旧兼容)/ ref(key 引用,客户端实例可自定义)。 */
|
|
659
|
+
async function query(params) {
|
|
660
|
+
const provider = params && params.provider
|
|
661
|
+
const adapter = params && params.adapter
|
|
662
|
+
const mapped = provider ? LEGACY_PROVIDER_MAP[provider] : null
|
|
663
|
+
// 显式给了 provider/adapter 但解析不到 → 未知(404);完全没给 → 默认 usage-percent
|
|
664
|
+
const id = adapter || mapped || (provider || adapter ? null : 'usage-percent')
|
|
665
|
+
const rawLabel = adapter || provider || DEFAULT_PROVIDER_ID
|
|
666
|
+
const cfg = ADAPTERS.find((a) => a.id === id)
|
|
667
|
+
if (!cfg) {
|
|
668
|
+
return {
|
|
669
|
+
ok: false, providerId: rawLabel, displayName: null, valid: null, planName: null,
|
|
670
|
+
remaining: null, unit: '%', extra: null, windows: null, cards: null,
|
|
671
|
+
isValid: false, invalidMessage: null, fetchedAt: null, cached: false, stale: false,
|
|
672
|
+
error: { type: 'unknown-provider', message: '未知供应商:' + rawLabel, httpStatus: 404 },
|
|
673
|
+
credential: null, snapshots: [], parseVersion: PARSE_VERSION,
|
|
674
|
+
config: { baseUrl: null, timeoutMs: config.timeoutMs, provider: rawLabel },
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const ref = (typeof (params && params.ref) === 'string' && params.ref.trim() !== '') ? params.ref.trim() : cfg.defaultCredentialRef
|
|
678
|
+
const fromVault = !!(params && params.source === 'vault')
|
|
679
|
+
const force = !!(params && (params.noCache === '1' || params.noCache === 'true'))
|
|
680
|
+
const key = id + '|' + ref + '|' + (fromVault ? 'vault' : 'dsh')
|
|
681
|
+
const state = stateOf(key)
|
|
682
|
+
// 30s 新鲜窗口内直接返回缓存(noCache=1 跳过:改 Key 后强制刷新)
|
|
683
|
+
if (!force && state.lastGood && Date.now() - state.lastGood.queriedAt < CACHE_FRESH_MS) {
|
|
684
|
+
const prev = state.lastGood
|
|
685
|
+
return { ...okResponse(cfg, prev.parsed, state, { fetchedAt: prev.queriedAt, credential: prev.credential }), cached: true }
|
|
686
|
+
}
|
|
687
|
+
if (!force && state.fetching) return state.fetching
|
|
688
|
+
state.fetching = doQuery(cfg, ref, fromVault)
|
|
689
|
+
.catch((e) => failTransient(cfg, state, 'internal', String((e && e.message) || e)))
|
|
690
|
+
.finally(() => { state.fetching = null })
|
|
691
|
+
return state.fetching
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── 本地 Token 统计(DSH 会话日志,按天物化存储;历史天封存,只算今天)──
|
|
695
|
+
function localFail(state, type, message) {
|
|
696
|
+
if (state.lastGood) {
|
|
697
|
+
return { ...state.lastGood.payload, cached: true, stale: true, ok: false, error: { type, message, httpStatus: null } }
|
|
698
|
+
}
|
|
699
|
+
return { ok: false, error: { type, message, httpStatus: null }, provider: null, detected: true, days: 0, scanned: 0, totals: null, byModel: null, providers: [], fetchedAt: null, cached: false, stale: false, snapshots: (state.snapshots || []).slice(), parseVersion: LOCAL_PARSE_VERSION }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** 校验待查天的 deps:unique 路径并行 stat,按天判定 mtime 变化/缺失,必要时重算该天并刷新缓存。 */
|
|
703
|
+
async function validateHistoryDeps(dayData, pending, state) {
|
|
704
|
+
const uniquePaths = new Set()
|
|
705
|
+
for (const day of pending) {
|
|
706
|
+
for (const p of Object.keys(dayData[day].deps || {})) uniquePaths.add(p)
|
|
707
|
+
}
|
|
708
|
+
// path -> { currentMtime } | { missing: true }(只 stat 一次,按天各自比较)
|
|
709
|
+
const current = new Map()
|
|
710
|
+
await Promise.all([...uniquePaths].map(async (path) => {
|
|
711
|
+
try {
|
|
712
|
+
const st = await fsStat(path)
|
|
713
|
+
current.set(path, { currentMtime: st.mtimeMs })
|
|
714
|
+
} catch { current.set(path, { missing: true }) }
|
|
715
|
+
}))
|
|
716
|
+
for (const day of pending) {
|
|
717
|
+
const data = dayData[day]
|
|
718
|
+
const changed = []
|
|
719
|
+
const missing = []
|
|
720
|
+
for (const p of Object.keys(data.deps || {})) {
|
|
721
|
+
const c = current.get(p)
|
|
722
|
+
if (!c) continue
|
|
723
|
+
if (c.missing) missing.push(p)
|
|
724
|
+
else if (Math.abs(c.currentMtime - Number(data.deps[p])) > 1) changed.push(p)
|
|
725
|
+
}
|
|
726
|
+
if (changed.length > 0 || missing.length > 0) {
|
|
727
|
+
const repaired = await recomputeDay(day, data.deps || {})
|
|
728
|
+
data.byProvider = repaired.byProvider
|
|
729
|
+
data.byModel = repaired.byModel
|
|
730
|
+
}
|
|
731
|
+
state.validatedDays.add(day)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** 重算某一天:读 deps 中仍存在的会话文件,fold 当天窗口,重新写回天文件。 */
|
|
735
|
+
async function recomputeDay(day, deps) {
|
|
736
|
+
const start = daily.dayStartMs(day)
|
|
737
|
+
const end = daily.dayStartMs(daily.addDays(day, 1))
|
|
738
|
+
const out = { byProvider: {}, byModel: {} }
|
|
739
|
+
const newDeps = {}
|
|
740
|
+
const paths = deps && Object.keys(deps).length > 0 ? Object.keys(deps) : null
|
|
741
|
+
if (paths) {
|
|
742
|
+
for (const p of paths) {
|
|
743
|
+
let mtimeMs = null
|
|
744
|
+
try { const st = await fsStat(p); mtimeMs = st.mtimeMs } catch { continue } // 会话已删:该天剔除其贡献
|
|
745
|
+
const text = await daily.readSessionFile(p)
|
|
746
|
+
if (!text) continue
|
|
747
|
+
daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(text), start, end, null))
|
|
748
|
+
newDeps[p] = mtimeMs
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const doc = { version: daily.DAILY_VERSION, date: day, deps: newDeps, byProvider: out.byProvider, byModel: out.byModel }
|
|
752
|
+
await daily.writeDayFile(config.dailyDir, day, doc)
|
|
753
|
+
rememberDay(day, doc)
|
|
754
|
+
return out
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** 首次全量回填:并行解码全部会话文件,按事件时间分天落盘(含今天与 unknown 桶),一次性。 */
|
|
758
|
+
async function backfillAll(nowMs) {
|
|
759
|
+
const toDay = daily.dayKey(nowMs)
|
|
760
|
+
const files = await daily.scanSessionFiles(config.sessionsDir, config.maxSessions, 0)
|
|
761
|
+
const days = new Map()
|
|
762
|
+
const cursors = await daily.loadCursors(config.dailyDir)
|
|
763
|
+
await Promise.all(files.map(async (f) => {
|
|
764
|
+
const text = await daily.readSessionFile(f.path)
|
|
765
|
+
cursors[f.path] = { offset: f.size || 0, mtimeMs: f.mtimeMs }
|
|
766
|
+
if (!text) return
|
|
767
|
+
const folded = daily.foldEventsByDays(daily.parseEvents(text))
|
|
768
|
+
for (const [day, fold] of Object.entries(folded)) {
|
|
769
|
+
let bucket = days.get(day)
|
|
770
|
+
if (!bucket) { bucket = { byProvider: {}, byModel: {}, deps: {} }; days.set(day, bucket) }
|
|
771
|
+
daily.mergeTotals(bucket, fold)
|
|
772
|
+
bucket.deps[f.path] = f.mtimeMs
|
|
773
|
+
}
|
|
774
|
+
}))
|
|
775
|
+
await daily.saveCursors(config.dailyDir, cursors)
|
|
776
|
+
for (const [day, bucket] of days.entries()) {
|
|
777
|
+
const doc = {
|
|
778
|
+
version: daily.DAILY_VERSION, date: day === 'unknown' ? 'unknown' : day,
|
|
779
|
+
deps: bucket.deps, byProvider: bucket.byProvider, byModel: bucket.byModel,
|
|
780
|
+
}
|
|
781
|
+
await daily.writeDayFile(config.dailyDir, day, doc)
|
|
782
|
+
rememberDay(day, doc)
|
|
783
|
+
}
|
|
784
|
+
if (days.has('unknown') && toDay && !days.has(toDay)) {
|
|
785
|
+
// unknown 兜底:若没有任何可归天的数据也要让 listDayFiles 非空(避免反复回填)
|
|
786
|
+
const doc = { version: daily.DAILY_VERSION, date: toDay, deps: {}, byProvider: {}, byModel: {} }
|
|
787
|
+
await daily.writeDayFile(config.dailyDir, toDay, doc)
|
|
788
|
+
rememberDay(toDay, doc)
|
|
789
|
+
}
|
|
790
|
+
return days.size
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** 计算今天的 totals:只扫描今天变过的会话文件(mtime >= 今天零点 - 1h 缓冲),
|
|
794
|
+
* 增量解码——mtime 未变的文件跳过(零读取),mtime 变化的文件只解上次偏移之后的新帧。 */
|
|
795
|
+
async function computeToday(nowMs) {
|
|
796
|
+
const toDay = daily.dayKey(nowMs)
|
|
797
|
+
const todayStart = daily.dayStartMs(toDay)
|
|
798
|
+
const todayEnd = daily.dayStartMs(daily.addDays(toDay, 1))
|
|
799
|
+
const files = await daily.scanSessionFiles(config.sessionsDir, config.maxSessions, todayStart - 3600000)
|
|
800
|
+
const cursors = await daily.loadCursors(config.dailyDir)
|
|
801
|
+
// 今天 totals = 上次累积的天文件 + 本轮新增帧(文件 mtime 未变则完全跳过)
|
|
802
|
+
const prevDay = await cachedDay(toDay)
|
|
803
|
+
const out = prevDay ? {
|
|
804
|
+
byProvider: JSON.parse(JSON.stringify(prevDay.byProvider || {})),
|
|
805
|
+
byModel: JSON.parse(JSON.stringify(prevDay.byModel || {})),
|
|
806
|
+
} : { byProvider: {}, byModel: {} }
|
|
807
|
+
const deps = prevDay && prevDay.deps ? { ...prevDay.deps } : {}
|
|
808
|
+
let anyWork = false
|
|
809
|
+
for (const f of files) {
|
|
810
|
+
deps[f.path] = f.mtimeMs
|
|
811
|
+
const cur = cursors[f.path]
|
|
812
|
+
if (cur && cur.mtimeMs === f.mtimeMs) continue // 文件无新写入:本轮零成本跳过
|
|
813
|
+
anyWork = true
|
|
814
|
+
if (cur && typeof cur.offset === 'number') {
|
|
815
|
+
const inc = await daily.readSessionFileFrom(f.path, cur.offset)
|
|
816
|
+
if (inc.changed && inc.text) {
|
|
817
|
+
daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(inc.text), todayStart, todayEnd, null))
|
|
818
|
+
cursors[f.path] = { offset: inc.total, mtimeMs: f.mtimeMs }
|
|
819
|
+
continue
|
|
820
|
+
}
|
|
821
|
+
if (inc.changed && !inc.text) {
|
|
822
|
+
cursors[f.path] = { offset: inc.total, mtimeMs: f.mtimeMs }
|
|
823
|
+
continue // 无新增内容
|
|
824
|
+
}
|
|
825
|
+
// 无法按偏移衔接 → 回退全量
|
|
826
|
+
}
|
|
827
|
+
const text2 = await daily.readSessionFile(f.path)
|
|
828
|
+
if (text2) {
|
|
829
|
+
daily.mergeTotals(out, daily.foldEventsByDay(daily.parseEvents(text2), todayStart, todayEnd, null))
|
|
830
|
+
}
|
|
831
|
+
cursors[f.path] = { offset: f.size || 0, mtimeMs: f.mtimeMs }
|
|
832
|
+
}
|
|
833
|
+
// 无任何文件变化 → 天文件与游标内容不变,跳过两次写盘
|
|
834
|
+
if (anyWork) {
|
|
835
|
+
await daily.saveCursors(config.dailyDir, cursors)
|
|
836
|
+
const doc = { version: daily.DAILY_VERSION, date: toDay, deps, byProvider: out.byProvider, byModel: out.byModel }
|
|
837
|
+
await daily.writeDayFile(config.dailyDir, toDay, doc)
|
|
838
|
+
rememberDay(toDay, doc)
|
|
839
|
+
}
|
|
840
|
+
return { toDay, out, scanned: files.length }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function doLocalUsage(target, since, key, state) {
|
|
844
|
+
let st
|
|
845
|
+
try { st = await fsStat(config.sessionsDir) } catch { return localFail(state, 'no-sessions', '未找到 DSH 会话目录:' + config.sessionsDir) }
|
|
846
|
+
await daily.initDecoder() // 首次懒加载 zstd-wasm(测试注入 identity 时为 no-op)
|
|
847
|
+
const nowMs = Date.now()
|
|
848
|
+
// deps 周期校验:5 分钟内只验证一次,过期则清空已校验集合(下次请求重校验)
|
|
849
|
+
if (!state.depsCheckAt || nowMs - state.depsCheckAt >= LOCAL_DEPS_CHECK_MS) {
|
|
850
|
+
state.validatedDays = new Set()
|
|
851
|
+
state.depsCheckAt = nowMs
|
|
852
|
+
}
|
|
853
|
+
const toDay = daily.dayKey(nowMs)
|
|
854
|
+
// 回填触发:没有历史天文件(含旧版只写了今天的遗留)且未做过回填 → 全量扫历史按天落盘
|
|
855
|
+
const existingDays = await daily.listDayFiles(config.dailyDir)
|
|
856
|
+
const hasHist = existingDays.some((d) => d < toDay)
|
|
857
|
+
if (!hasHist && !(await daily.hasBackfilled(config.dailyDir))) {
|
|
858
|
+
await backfillAll(nowMs)
|
|
859
|
+
await daily.writeBackfilled(config.dailyDir)
|
|
860
|
+
}
|
|
861
|
+
const agg = { byProvider: {}, byModel: {} }
|
|
862
|
+
const dayCount = new Set()
|
|
863
|
+
dayCount.add(toDay)
|
|
864
|
+
// 今天:只算今天变过的文件
|
|
865
|
+
const today = await computeToday(nowMs)
|
|
866
|
+
daily.mergeTotals(agg, today.out)
|
|
867
|
+
// 历史天:读天文件(内存缓存;5 分钟周期内只校验一次 deps;mtime 变化才重算该天)
|
|
868
|
+
const existing = await daily.listDayFiles(config.dailyDir)
|
|
869
|
+
const pending = []
|
|
870
|
+
const dayData = {}
|
|
871
|
+
for (const day of existing) {
|
|
872
|
+
if (day >= toDay) continue
|
|
873
|
+
if (since > 0 && daily.dayStartMs(day) < since) continue
|
|
874
|
+
const data = await cachedDay(day)
|
|
875
|
+
if (!data) continue
|
|
876
|
+
dayData[day] = data
|
|
877
|
+
if (!state.validatedDays || !state.validatedDays.has(day)) pending.push(day)
|
|
878
|
+
}
|
|
879
|
+
if (pending.length > 0) {
|
|
880
|
+
state.validatedDays = state.validatedDays || new Set()
|
|
881
|
+
await validateHistoryDeps(dayData, pending, state)
|
|
882
|
+
}
|
|
883
|
+
for (const day of Object.keys(dayData)) {
|
|
884
|
+
const data = dayData[day]
|
|
885
|
+
daily.mergeTotals(agg, { byProvider: data.byProvider || {}, byModel: data.byModel || {} })
|
|
886
|
+
dayCount.add(day)
|
|
887
|
+
}
|
|
888
|
+
// unknown 桶(time 缺失事件):并入聚合(不占天数)
|
|
889
|
+
const unknownData = await cachedDay('unknown')
|
|
890
|
+
if (unknownData) {
|
|
891
|
+
daily.mergeTotals(agg, { byProvider: unknownData.byProvider || {}, byModel: unknownData.byModel || {} })
|
|
892
|
+
}
|
|
893
|
+
// 组装响应(chips 只过滤 totals;byModel 恒为全量)
|
|
894
|
+
const providers = Object.keys(agg.byProvider).sort()
|
|
895
|
+
const pick = target ? (agg.byProvider[target] || null) : null
|
|
896
|
+
const t = pick || { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }
|
|
897
|
+
if (!pick) {
|
|
898
|
+
for (const k of providers) {
|
|
899
|
+
t.requests += agg.byProvider[k].requests || 0
|
|
900
|
+
t.inputTokens += agg.byProvider[k].inputTokens || 0
|
|
901
|
+
t.outputTokens += agg.byProvider[k].outputTokens || 0
|
|
902
|
+
t.cacheReadTokens += agg.byProvider[k].cacheReadTokens || 0
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const payload = {
|
|
906
|
+
ok: true, error: null, message: null,
|
|
907
|
+
provider: target || null, detected: true,
|
|
908
|
+
days: dayCount.size,
|
|
909
|
+
scanned: today.scanned,
|
|
910
|
+
totals: {
|
|
911
|
+
requests: t.requests,
|
|
912
|
+
inputTokens: t.inputTokens,
|
|
913
|
+
outputTokens: t.outputTokens,
|
|
914
|
+
cacheReadTokens: t.cacheReadTokens,
|
|
915
|
+
realTotalTokens: t.inputTokens + t.outputTokens + t.cacheReadTokens,
|
|
916
|
+
cacheHitRate: (t.inputTokens + t.cacheReadTokens) > 0
|
|
917
|
+
? t.cacheReadTokens / (t.inputTokens + t.cacheReadTokens)
|
|
918
|
+
: 0,
|
|
919
|
+
},
|
|
920
|
+
byModel: agg.byModel || null,
|
|
921
|
+
providers,
|
|
922
|
+
fetchedAt: Date.now(), cached: false, stale: false,
|
|
923
|
+
snapshots: (state.snapshots || []).slice(),
|
|
924
|
+
parseVersion: LOCAL_PARSE_VERSION,
|
|
925
|
+
}
|
|
926
|
+
recordSnapshot(key, { attemptAt: Date.now(), httpStatus: null, error: null })
|
|
927
|
+
state.lastGood = { queriedAt: Date.now(), payload }
|
|
928
|
+
return payload
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function localUsage(params) {
|
|
932
|
+
const target = (params && typeof params.provider === 'string' && params.provider.trim() !== '') ? params.provider.trim() : null
|
|
933
|
+
const since = (params && typeof params.since === 'string' && Number(params.since) > 0) ? Number(params.since) : 0
|
|
934
|
+
const force = !!(params && (params.noCache === '1' || params.noCache === 'true'))
|
|
935
|
+
const key = 'local|' + (target || '*') + '|' + since
|
|
936
|
+
const state = stateOf(key)
|
|
937
|
+
if (!force && state.lastGood && Date.now() - state.lastGood.queriedAt < CACHE_FRESH_MS) {
|
|
938
|
+
return { ...state.lastGood.payload, cached: true }
|
|
939
|
+
}
|
|
940
|
+
if (!force && state.fetching) return state.fetching
|
|
941
|
+
state.fetching = doLocalUsage(target, since, key, state)
|
|
942
|
+
.catch((e) => localFail(state, 'internal', String((e && e.message) || e)))
|
|
943
|
+
.finally(() => { state.fetching = null })
|
|
944
|
+
return state.fetching
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
function readBodyJson(req) {
|
|
949
|
+
return new Promise((resolve) => {
|
|
950
|
+
let raw = ''
|
|
951
|
+
let done = false
|
|
952
|
+
const finish = (v) => { if (!done) { done = true; resolve(v) } }
|
|
953
|
+
try {
|
|
954
|
+
req.on('data', (chunk) => { raw += String(chunk); if (raw.length > 8192) finish(null) })
|
|
955
|
+
req.on('end', () => { try { finish(JSON.parse(raw || '{}')) } catch (e) { finish(null) } })
|
|
956
|
+
req.on('error', () => finish(null))
|
|
957
|
+
} catch (e) { finish(null) }
|
|
958
|
+
})
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const jsonRoute = (handler, allowPost) => async (req, res) => {
|
|
962
|
+
if (!isLoopbackRequest(req)) {
|
|
963
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
964
|
+
res.end(JSON.stringify({ error: 'forbidden: loopback-only' }))
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && (!allowPost || req.method !== 'POST')) {
|
|
968
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
969
|
+
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
const body = req.method === 'POST' ? await readBodyJson(req) : undefined
|
|
974
|
+
const result = await handler(req, body)
|
|
975
|
+
res.writeHead(result.error && result.error.httpStatus ? result.error.httpStatus : 200, { 'content-type': 'application/json; charset=utf-8' })
|
|
976
|
+
res.end(JSON.stringify(result))
|
|
977
|
+
} catch (e) {
|
|
978
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
979
|
+
res.end(JSON.stringify({ ok: false, error: { type: 'internal', message: String((e && e.message) || e) } }))
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** 从 URL 提取查询参数:adapter / provider(旧兼容)/ ref(key 引用)。 */
|
|
984
|
+
const queryParamsFromUrl = (req) => {
|
|
985
|
+
try {
|
|
986
|
+
if (!req.url) return {}
|
|
987
|
+
const u = new URL(req.url, 'http://localhost')
|
|
988
|
+
const out = {}
|
|
989
|
+
const p = u.searchParams.get('provider'); if (p && p.trim()) out.provider = p.trim()
|
|
990
|
+
const a = u.searchParams.get('adapter'); if (a && a.trim()) out.adapter = a.trim()
|
|
991
|
+
const r = u.searchParams.get('ref'); if (r && r.trim()) out.ref = r.trim()
|
|
992
|
+
const s = u.searchParams.get('source'); if (s && s.trim()) out.source = s.trim()
|
|
993
|
+
const n = u.searchParams.get('noCache'); if (n && (n === '1' || n === 'true')) out.noCache = '1'
|
|
994
|
+
const t = u.searchParams.get('since'); if (t && /^[0-9]+$/.test(t)) out.since = t
|
|
995
|
+
return out
|
|
996
|
+
} catch (e) {
|
|
997
|
+
return {}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
ctx.effect(() => {
|
|
1002
|
+
const disposers = [
|
|
1003
|
+
// 兼容旧版:固定 opencode-go → usage-percent 适配器
|
|
1004
|
+
ctx.webServer.register({
|
|
1005
|
+
kind: 'exact',
|
|
1006
|
+
path: '/api/provider-usage/opencode-go',
|
|
1007
|
+
handler: jsonRoute(() => query({})),
|
|
1008
|
+
}),
|
|
1009
|
+
// 多实例查询:/api/provider-usage/query?adapter=<id>&ref=<credentialRef>
|
|
1010
|
+
ctx.webServer.register({
|
|
1011
|
+
kind: 'exact',
|
|
1012
|
+
path: '/api/provider-usage/query',
|
|
1013
|
+
handler: jsonRoute((req) => query(queryParamsFromUrl(req))),
|
|
1014
|
+
}),
|
|
1015
|
+
// 本地 Token 统计(DSH 会话日志,只读、不联网、与实例/Key 解耦)
|
|
1016
|
+
ctx.webServer.register({
|
|
1017
|
+
kind: 'exact',
|
|
1018
|
+
path: '/api/provider-usage/local-usage',
|
|
1019
|
+
handler: jsonRoute((req) => localUsage(queryParamsFromUrl(req))),
|
|
1020
|
+
}),
|
|
1021
|
+
|
|
1022
|
+
// 适配器清单(不含任何 secret;客户端据此渲染"添加供应商"选项)
|
|
1023
|
+
ctx.webServer.register({
|
|
1024
|
+
kind: 'exact',
|
|
1025
|
+
path: '/api/provider-usage/templates',
|
|
1026
|
+
handler: jsonRoute(() => ({
|
|
1027
|
+
ok: true,
|
|
1028
|
+
items: ADAPTERS.map((a) => ({ id: a.id, displayName: a.displayName, description: a.description })),
|
|
1029
|
+
})),
|
|
1030
|
+
}),
|
|
1031
|
+
// 手动 Key 写入/删除:值加密进 $DSH_HOME/provider-usage(方案 B,不落明文、不进 DSH 凭证)
|
|
1032
|
+
ctx.webServer.register({
|
|
1033
|
+
kind: 'exact',
|
|
1034
|
+
path: '/api/provider-usage/credentials',
|
|
1035
|
+
handler: jsonRoute(async (req, body) => {
|
|
1036
|
+
// 私有库键独立命名空间(非 DSH 引用名):允许字母/数字/下划线/连字符
|
|
1037
|
+
const keyOk = (k) => typeof k === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(k)
|
|
1038
|
+
if (req.method === 'POST') {
|
|
1039
|
+
const ref = body && typeof body.ref === 'string' ? body.ref.trim() : ''
|
|
1040
|
+
const value = body && typeof body.value === 'string' ? body.value : ''
|
|
1041
|
+
if (!keyOk(ref)) return { ok: false, error: { type: 'bad-ref', message: 'Key 标识不合法(仅限字母/数字/下划线/连字符)', httpStatus: 400 } }
|
|
1042
|
+
if (!value) return { ok: false, error: { type: 'bad-value', message: 'Key 值不能为空', httpStatus: 400 } }
|
|
1043
|
+
await (await secureStore()).set(ref, value)
|
|
1044
|
+
return { ok: true, ref }
|
|
1045
|
+
}
|
|
1046
|
+
const ref = typeof req.url === 'string' ? (new URL(req.url, 'http://localhost').searchParams.get('ref') || '') : ''
|
|
1047
|
+
if (!keyOk(ref)) return { ok: false, error: { type: 'bad-ref', message: 'Key 标识不合法', httpStatus: 400 } }
|
|
1048
|
+
await (await secureStore()).remove(ref)
|
|
1049
|
+
return { ok: true, ref }
|
|
1050
|
+
}, true),
|
|
1051
|
+
}),
|
|
1052
|
+
// 可从 DSH 导入的供应商(仅我们适配:OpenCode Go 订阅 / DeepSeek 余额)
|
|
1053
|
+
ctx.webServer.register({
|
|
1054
|
+
kind: 'exact',
|
|
1055
|
+
path: '/api/provider-usage/dsh-providers',
|
|
1056
|
+
handler: jsonRoute(async () => {
|
|
1057
|
+
let settingsCfg = null
|
|
1058
|
+
try { settingsCfg = ctx.settings.get('llm-pi-ai') } catch (e) { /* ignore */ }
|
|
1059
|
+
if (!settingsCfg) { try { settingsCfg = ctx.settings.get('settings.llm-pi-ai') } catch (e) { /* ignore */ } }
|
|
1060
|
+
const IMPORTABLE = [
|
|
1061
|
+
{ route: 'opencode-go', displayName: 'OpenCode Go', adapter: 'usage-percent', ref: 'OPENCODE_GO_API_KEY', settingsKey: 'opencode-go' },
|
|
1062
|
+
{ route: 'deepseek-official', displayName: 'DeepSeek', adapter: 'balance-json', ref: 'DEEPSEEK_API_KEY', settingsKey: 'deepseek' },
|
|
1063
|
+
]
|
|
1064
|
+
const items = []
|
|
1065
|
+
for (const imp of IMPORTABLE) {
|
|
1066
|
+
const prov = settingsCfg && settingsCfg.providers && settingsCfg.providers[imp.settingsKey]
|
|
1067
|
+
const ref = prov && typeof prov.apiKeyEnv === 'string' && prov.apiKeyEnv.trim() !== '' ? prov.apiKeyEnv.trim() : imp.ref
|
|
1068
|
+
const displayName = prov && typeof prov.displayName === 'string' && prov.displayName ? prov.displayName : imp.displayName
|
|
1069
|
+
items.push({ route: imp.route, displayName, adapter: imp.adapter, ref, configured: await refConfigured(ref) })
|
|
1070
|
+
}
|
|
1071
|
+
return { ok: true, items }
|
|
1072
|
+
}),
|
|
1073
|
+
}),
|
|
1074
|
+
// 按需检查 Key 引用的配置状态(不含值;官方 Models 页同款的 credentials.describe)
|
|
1075
|
+
ctx.webServer.register({
|
|
1076
|
+
kind: 'exact',
|
|
1077
|
+
path: '/api/provider-usage/credential-refs',
|
|
1078
|
+
handler: jsonRoute(async (req) => {
|
|
1079
|
+
const list = []
|
|
1080
|
+
try {
|
|
1081
|
+
if (req.url) {
|
|
1082
|
+
const u = new URL(req.url, 'http://localhost')
|
|
1083
|
+
const raw = u.searchParams.get('refs')
|
|
1084
|
+
if (raw) {
|
|
1085
|
+
for (const part of raw.split(',')) {
|
|
1086
|
+
const name = part.trim()
|
|
1087
|
+
// 宽松校验:兼容 DSH 引用名与私有库键(实例 id 含连字符)
|
|
1088
|
+
if (name && /^[A-Za-z0-9_-]{1,64}$/.test(name) && list.length < 24) list.push(name)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} catch (e) { /* ignore */ }
|
|
1093
|
+
const refs = []
|
|
1094
|
+
for (const name of list) {
|
|
1095
|
+
const store = await refStoreOf(name)
|
|
1096
|
+
refs.push({ name, configured: store !== null, store })
|
|
1097
|
+
}
|
|
1098
|
+
return { ok: true, refs }
|
|
1099
|
+
}),
|
|
1100
|
+
}),
|
|
1101
|
+
]
|
|
1102
|
+
return () => { for (const dispose of disposers) { if (typeof dispose === 'function') dispose() } }
|
|
1103
|
+
}, 'provider-usage: routes')
|
|
1104
|
+
}
|