dsh-cost-meter 1.5.5
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.en.md +316 -0
- package/README.md +320 -0
- package/cordis.patch.yml +6 -0
- package/docs/provider-pricing.json +777 -0
- package/lib/backfill.js +405 -0
- package/lib/client.js +4116 -0
- package/lib/coding-plans.js +346 -0
- package/lib/custom-balance.js +147 -0
- package/lib/index.js +975 -0
- package/lib/pricing.js +799 -0
- package/lib/store.js +990 -0
- package/lib/typert.host.js +402 -0
- package/package.json +76 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coding Plan 额度查询 adapter 框架(多厂商)。
|
|
3
|
+
*
|
|
4
|
+
* 每家厂商一个 adapter:固定官方端点白名单 + Key 发现线索(env/CLI 文件)+
|
|
5
|
+
* 响应解析器。解析器为纯函数(可单测);网络与凭据解析在宿主侧(index.js)。
|
|
6
|
+
*
|
|
7
|
+
* 归一化输出:windows = { [name]: { percent: 0-100, resetsAt: ISO 字符串 } }。
|
|
8
|
+
* 凭证安全:每个 adapter 的 URL 均为硬编码官方域名,Key 永不发往其它域。
|
|
9
|
+
*
|
|
10
|
+
* 实测确认(2026-08):
|
|
11
|
+
* - Anthropic OAuth usage 端点存活(未授权返回限流/401);
|
|
12
|
+
* - Z.ai / 智谱 Coding Plan usage 端点存活(401「token expired or incorrect」);
|
|
13
|
+
* - MiniMax Token Plan remains 端点存活(1004 需 Authorization);
|
|
14
|
+
* - Kimi PAYG 余额端点 api.moonshot.cn/v1/users/me/balance 存活(401 incorrect_api_key,官方文档明确);
|
|
15
|
+
* Kimi Code 订阅周窗/5小时窗暂无 API-Key 化公开端点(仅 kimi.com 控制台),以余额窗口接入;
|
|
16
|
+
* - OpenRouter credits 端点 openrouter.ai/api/v1/credits 存活(401,官方文档明确);
|
|
17
|
+
* - SiliconFlow 用户信息端点 api.siliconflow.cn/v1/user/info 存活(30014 Token is invalid);
|
|
18
|
+
* - 百炼 Coding Plan / OpenAI Codex / Gemini Code Assist / GitHub Copilot 个人版暂无 API-Key 化公开用量端点(仅控制台/组织级 API),不接入。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const CODING_PLAN_PROVIDERS = {
|
|
22
|
+
anthropic: {
|
|
23
|
+
label: 'Anthropic (Claude Pro/Max)',
|
|
24
|
+
credentialEnvs: ['ANTHROPIC_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
|
25
|
+
keyHint: 'Claude Code OAuth access token(~/.claude/.credentials.json)',
|
|
26
|
+
},
|
|
27
|
+
zai: {
|
|
28
|
+
label: 'Z.ai / 智谱 GLM Coding Plan',
|
|
29
|
+
credentialEnvs: ['ZAI_API_KEY', 'BIGMODEL_API_KEY'],
|
|
30
|
+
keyHint: 'Coding Plan 专属 API Key(z.ai / bigmodel.cn 控制台)',
|
|
31
|
+
},
|
|
32
|
+
minimax: {
|
|
33
|
+
label: 'MiniMax Token Plan',
|
|
34
|
+
credentialEnvs: ['MINIMAX_API_KEY'],
|
|
35
|
+
keyHint: 'MiniMax API Key(sk-* / sk-cp-*)',
|
|
36
|
+
},
|
|
37
|
+
kimi: {
|
|
38
|
+
label: 'Kimi / Moonshot',
|
|
39
|
+
credentialEnvs: ['MOONSHOT_API_KEY', 'KIMI_API_KEY'],
|
|
40
|
+
keyHint: 'Moonshot 开放平台 API Key(sk-*;Kimi Code 订阅周窗暂无 API-Key 化端点,此处显示 PAYG 余额)',
|
|
41
|
+
},
|
|
42
|
+
openrouter: {
|
|
43
|
+
label: 'OpenRouter',
|
|
44
|
+
credentialEnvs: ['OPENROUTER_API_KEY'],
|
|
45
|
+
keyHint: 'OpenRouter API Key(sk-or-*;显示预付 credits 已用%)',
|
|
46
|
+
},
|
|
47
|
+
siliconflow: {
|
|
48
|
+
label: 'SiliconFlow 硅基流动',
|
|
49
|
+
credentialEnvs: ['SILICONFLOW_API_KEY'],
|
|
50
|
+
keyHint: 'SiliconFlow API Key(sk-*;显示账户余额)',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const CODING_PLAN_PROVIDER_IDS = Object.keys(CODING_PLAN_PROVIDERS)
|
|
55
|
+
|
|
56
|
+
/** 归一化百分比:0-1 视为小数,>=1 视为已是百分数;非法 → null。 */
|
|
57
|
+
export function normalizePercent(value) {
|
|
58
|
+
const n = Number(value)
|
|
59
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
60
|
+
const pct = n <= 1 ? n * 100 : n
|
|
61
|
+
return Math.min(100, Math.round(pct * 10) / 10)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 归一化重置时刻:unix 秒 / unix 毫秒 / ISO 字符串 → ISO 字符串;非法 → ''。 */
|
|
65
|
+
export function normalizeResetAt(value) {
|
|
66
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
67
|
+
const ms = Date.parse(value)
|
|
68
|
+
if (Number.isFinite(ms)) return new Date(ms).toISOString()
|
|
69
|
+
const asNum = Number(value)
|
|
70
|
+
if (Number.isFinite(asNum) && asNum > 0) return new Date(asNum > 1e12 ? asNum : asNum * 1000).toISOString()
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
const n = Number(value)
|
|
74
|
+
if (!Number.isFinite(n) || n <= 0) return ''
|
|
75
|
+
return new Date(n > 1e12 ? n : n * 1000).toISOString()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 组装单个百分比窗口;percent 非法时返回 null。 */
|
|
79
|
+
function windowOf(percent, resetsAt) {
|
|
80
|
+
const pct = normalizePercent(percent)
|
|
81
|
+
if (pct === null) return null
|
|
82
|
+
return { percent: pct, resetsAt: normalizeResetAt(resetsAt) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 组装文本窗口(余额等无百分比的量):text 空 → null。 */
|
|
86
|
+
function textWindowOf(text) {
|
|
87
|
+
const s = typeof text === 'string' ? text.trim() : String(text ?? '').trim()
|
|
88
|
+
return s.length > 0 ? { resetsAt: '', text: s } : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 解析 Anthropic OAuth 用量响应(GET https://api.anthropic.com/api/oauth/usage)。
|
|
93
|
+
* 形如 { five_hour: { utilization, resets_at }, seven_day: {...}, seven_day_sonnet: {...}, extra_usage: {...} }。
|
|
94
|
+
* utilization 为 0-100 百分数,resets_at 为 unix 秒。
|
|
95
|
+
*/
|
|
96
|
+
export function parseAnthropicUsage(data) {
|
|
97
|
+
if (data === null || typeof data !== 'object') return null
|
|
98
|
+
const windows = {}
|
|
99
|
+
for (const [name, raw] of Object.entries(data)) {
|
|
100
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
101
|
+
const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
|
|
102
|
+
if (win !== null) windows[name] = win
|
|
103
|
+
}
|
|
104
|
+
return Object.keys(windows).length > 0 ? windows : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 解析 Z.ai / 智谱 GLM Coding Plan 用量响应
|
|
109
|
+
* (GET {api.z.ai|open.bigmodel.cn}/api/coding/paas/{v3|v4}/dashboard/billing/coding_plan/usage;2026-08 起 v3 优先,见 CODING_PLAN_ENDPOINTS)。
|
|
110
|
+
* 兼容两种已见形态:
|
|
111
|
+
* - { plans: [{ status, total_units, used_units, available_units, period_end, capabilities }] }
|
|
112
|
+
* (period_end 语义按数值大小推断:重置跨度 >1 天视为周档,否则为 5 小时档)
|
|
113
|
+
* - { five_hour: { utilization|percent, resets_at }, weekly|week|seven_day: {...} }
|
|
114
|
+
*/
|
|
115
|
+
export function parseZaiUsage(data) {
|
|
116
|
+
if (data === null || typeof data !== 'object') return null
|
|
117
|
+
const windows = {}
|
|
118
|
+
// 形态一:plans 数组(zcode 逆向确认的计费 API 形状)。
|
|
119
|
+
if (Array.isArray(data.plans)) {
|
|
120
|
+
for (const plan of data.plans) {
|
|
121
|
+
if (plan === null || typeof plan !== 'object') continue
|
|
122
|
+
const total = Number(plan.total_units)
|
|
123
|
+
const used = Number(plan.used_units)
|
|
124
|
+
let pct = null
|
|
125
|
+
if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) {
|
|
126
|
+
pct = Math.min(100, (used / total) * 100)
|
|
127
|
+
} else {
|
|
128
|
+
pct = normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage)
|
|
129
|
+
}
|
|
130
|
+
if (pct === null) continue
|
|
131
|
+
const spanMs = Number(plan.period_end) * 1000 - Date.now()
|
|
132
|
+
// 5 小时档重置跨度必 <1 天;周档最长 7 天——以 1 天为界区分两档。
|
|
133
|
+
const key = Number.isFinite(spanMs) && spanMs > 24 * 3600_000 ? 'weekly' : 'fiveHour'
|
|
134
|
+
windows[key] = { percent: Math.round(pct * 10) / 10, resetsAt: normalizeResetAt(plan.period_end) }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// 形态二:与 Anthropic 相同的扁平窗口对象。
|
|
138
|
+
for (const [name, raw] of Object.entries(data)) {
|
|
139
|
+
if (name === 'plans' || raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
140
|
+
const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage, raw.resets_at ?? raw.reset_at ?? raw.resetsAt)
|
|
141
|
+
if (win !== null) windows[name] = win
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(windows).length > 0 ? windows : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 解析 MiniMax 用量响应。兼容两种官方形态:
|
|
148
|
+
* - Token Plan:GET https://www.minimaxi.com|io/v1/token_plan/remains
|
|
149
|
+
* 窗口数组字段(各档 5 小时固定窗 + 周窗),条目含 total/used/remain 与 interval 标签;
|
|
150
|
+
* - Coding Plan(旧计数制):GET .../v1/api/openplatform/coding_plan/remains
|
|
151
|
+
* { model_remains: [{ current_interval_total_count, current_interval_usage_count, ... }] }。
|
|
152
|
+
*/
|
|
153
|
+
export function parseMiniMaxRemains(data) {
|
|
154
|
+
if (data === null || typeof data !== 'object') return null
|
|
155
|
+
const windows = {}
|
|
156
|
+
const pickArray = (...keys) => {
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
const direct = Array.isArray(data?.[key]) ? data[key] : null
|
|
159
|
+
const nested = Array.isArray(data?.data?.[key]) ? data.data[key] : null
|
|
160
|
+
if (direct !== null) return direct
|
|
161
|
+
if (nested !== null) return nested
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
// Token Plan:窗口数组(字段名容错)。
|
|
166
|
+
const planRows = pickArray('token_plan_remains', 'plan_remains', 'remains', 'windows')
|
|
167
|
+
if (planRows !== null) {
|
|
168
|
+
planRows.forEach((row, index) => {
|
|
169
|
+
if (row === null || typeof row !== 'object') return
|
|
170
|
+
const total = Number(row.current_interval_total_count ?? row.total_count ?? row.total ?? row.limit)
|
|
171
|
+
const used = Number(row.current_interval_usage_count ?? row.used_count ?? row.usage_count ?? row.used)
|
|
172
|
+
const remain = Number(row.current_interval_remain_count ?? row.remain_count ?? row.remain ?? row.remaining)
|
|
173
|
+
let pct = null
|
|
174
|
+
if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
|
|
175
|
+
else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
|
|
176
|
+
else pct = normalizePercent(row.utilization ?? row.percent ?? row.used_percentage)
|
|
177
|
+
if (pct === null) return
|
|
178
|
+
const labelRaw = row.interval ?? row.interval_type ?? row.window_type ?? row.type ?? row.name
|
|
179
|
+
const label = typeof labelRaw === 'string' && labelRaw.length > 0 ? labelRaw : 'window' + String(index + 1)
|
|
180
|
+
windows[label] = {
|
|
181
|
+
percent: Math.max(0, Math.min(100, Math.round(pct * 10) / 10)),
|
|
182
|
+
resetsAt: normalizeResetAt(row.reset_time ?? row.resets_at ?? row.next_reset_time ?? row.reset_at),
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
// 旧 Coding Plan 计数制:model_remains。
|
|
187
|
+
const modelRows = pickArray('model_remains')
|
|
188
|
+
if (modelRows !== null) {
|
|
189
|
+
let total = 0
|
|
190
|
+
let used = 0
|
|
191
|
+
let found = false
|
|
192
|
+
for (const row of modelRows) {
|
|
193
|
+
if (row === null || typeof row !== 'object') continue
|
|
194
|
+
const t = Number(row.current_interval_total_count ?? row.total)
|
|
195
|
+
const u = Number(row.current_interval_usage_count ?? row.used)
|
|
196
|
+
if (!Number.isFinite(t) || t <= 0) continue
|
|
197
|
+
found = true
|
|
198
|
+
total += t
|
|
199
|
+
used += Number.isFinite(u) ? u : 0
|
|
200
|
+
}
|
|
201
|
+
if (found && total > 0) {
|
|
202
|
+
windows.current = {
|
|
203
|
+
percent: Math.min(100, Math.round((used / total) * 1000) / 10),
|
|
204
|
+
resetsAt: '',
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return Object.keys(windows).length > 0 ? windows : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 解析 Kimi / Moonshot 余额响应(GET https://api.moonshot.cn/v1/users/me/balance)。
|
|
213
|
+
* 官方返回形如 { available_balance: <分> }(人民币分),兼容 cached/total 变体与元单位形态。
|
|
214
|
+
* 输出文本窗口(余额无总量,不适合百分比进度条)。
|
|
215
|
+
*/
|
|
216
|
+
export function parseKimiBalance(data) {
|
|
217
|
+
if (data === null || typeof data !== 'object') return null
|
|
218
|
+
const raw = data.available_balance ?? data.balance ?? data.cash_balance ?? data.data?.available_balance
|
|
219
|
+
const n = Number(raw)
|
|
220
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
221
|
+
// 官方单位为人民币分;数值 <100 视为已是元(兼容变体)。
|
|
222
|
+
const cny = n >= 100 ? n / 100 : n
|
|
223
|
+
const text = '余额 ¥' + (Math.round(cny * 100) / 100).toFixed(2)
|
|
224
|
+
const win = textWindowOf(text)
|
|
225
|
+
return win === null ? null : { balance: win }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 解析 OpenRouter 额度响应(GET https://openrouter.ai/api/v1/credits)。
|
|
230
|
+
* 官方返回 { data: { total_credits, total_usage } }(美元);输出已用% 窗口。
|
|
231
|
+
*/
|
|
232
|
+
export function parseOpenRouterCredits(data) {
|
|
233
|
+
if (data === null || typeof data !== 'object') return null
|
|
234
|
+
const d = data.data !== null && typeof data.data === 'object' ? data.data : data
|
|
235
|
+
const total = Number(d.total_credits ?? d.credits)
|
|
236
|
+
const used = Number(d.total_usage ?? d.usage)
|
|
237
|
+
if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(used)) return null
|
|
238
|
+
const pct = Math.max(0, Math.min(100, Math.round((used / total) * 1000) / 10))
|
|
239
|
+
return { credits: { percent: pct, resetsAt: normalizeResetAt(d.resets_at ?? d.next_reset_time) } }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 解析 SiliconFlow 用户信息响应(GET https://api.siliconflow.cn/v1/user/info)。
|
|
244
|
+
* 余额字段容错(balance/amount/remain),输出文本窗口(人民币)。
|
|
245
|
+
*/
|
|
246
|
+
export function parseSiliconFlowInfo(data) {
|
|
247
|
+
if (data === null || typeof data !== 'object') return null
|
|
248
|
+
const d = data.data !== null && typeof data.data === 'object' ? data.data : data
|
|
249
|
+
const raw = d.balance ?? d.amount ?? d.remain ?? d.remaining
|
|
250
|
+
const n = Number(raw)
|
|
251
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
252
|
+
const win = textWindowOf('余额 ¥' + (Math.round(n * 100) / 100).toFixed(2))
|
|
253
|
+
return win === null ? null : { balance: win }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 各家固定官方端点(硬编码白名单;region 变体按序尝试)。 */
|
|
257
|
+
export const CODING_PLAN_ENDPOINTS = {
|
|
258
|
+
anthropic: ['https://api.anthropic.com/api/oauth/usage'],
|
|
259
|
+
zai: [
|
|
260
|
+
// 2026-08 接口变更:带有效 Coding Plan Key 请求 v4 返回 404,v3 存活(issue #17);v3 两域优先,v4 保留兑底。
|
|
261
|
+
'https://api.z.ai/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
|
|
262
|
+
'https://open.bigmodel.cn/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
|
|
263
|
+
'https://api.z.ai/api/coding/paas/v4/dashboard/billing/coding_plan/usage',
|
|
264
|
+
'https://open.bigmodel.cn/api/coding/paas/v4/dashboard/billing/coding_plan/usage',
|
|
265
|
+
],
|
|
266
|
+
minimax: [
|
|
267
|
+
'https://www.minimaxi.com/v1/token_plan/remains',
|
|
268
|
+
'https://www.minimax.io/v1/token_plan/remains',
|
|
269
|
+
'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
|
|
270
|
+
],
|
|
271
|
+
kimi: ['https://api.moonshot.cn/v1/users/me/balance'],
|
|
272
|
+
openrouter: ['https://openrouter.ai/api/v1/credits'],
|
|
273
|
+
siliconflow: ['https://api.siliconflow.cn/v1/user/info'],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const CODING_PLAN_PARSERS = {
|
|
277
|
+
anthropic: parseAnthropicUsage,
|
|
278
|
+
minimax: parseMiniMaxRemains,
|
|
279
|
+
zai: parseZaiUsage,
|
|
280
|
+
kimi: parseKimiBalance,
|
|
281
|
+
openrouter: parseOpenRouterCredits,
|
|
282
|
+
siliconflow: parseSiliconFlowInfo,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 查询单家 coding plan 额度。按 CODING_PLAN_ENDPOINTS 顺序尝试官方端点:
|
|
287
|
+
* 认证失败(401/403)与解析成功立即返回;其余错误尝试下一个端点。
|
|
288
|
+
* 预期场景(未找到 Key / 无订阅)抛出 error.soft = true 的软错误。
|
|
289
|
+
* @param provider - anthropic | zai | minimax | kimi | openrouter | siliconflow。
|
|
290
|
+
* @param key - 已解析出的 API Key / OAuth token;null 表示未找到。
|
|
291
|
+
* @param locale - 消息语言(zh/en)。
|
|
292
|
+
* @param t - 服务端文案函数 tmsg(locale, code, vars)。
|
|
293
|
+
* @returns {Promise<{ windows: object, endpoint: string }>}
|
|
294
|
+
*/
|
|
295
|
+
export async function queryCodingPlan(provider, key, locale, t) {
|
|
296
|
+
const meta = CODING_PLAN_PROVIDERS[provider]
|
|
297
|
+
if (meta === undefined) throw new Error(t(locale, 'codingPlanUnknown', { provider: String(provider) }))
|
|
298
|
+
if (key === null || typeof key !== 'string' || key.trim().length === 0) {
|
|
299
|
+
const error = new Error(t(locale, 'codingPlanKeyMissing', { provider: meta.label }))
|
|
300
|
+
error.soft = true
|
|
301
|
+
throw error
|
|
302
|
+
}
|
|
303
|
+
const urls = CODING_PLAN_ENDPOINTS[provider]
|
|
304
|
+
const parse = CODING_PLAN_PARSERS[provider]
|
|
305
|
+
let lastError = null
|
|
306
|
+
for (const url of urls) {
|
|
307
|
+
let response
|
|
308
|
+
try {
|
|
309
|
+
response = await fetch(url, {
|
|
310
|
+
headers: {
|
|
311
|
+
authorization: `Bearer ${key.trim()}`,
|
|
312
|
+
'user-agent': 'dsh-cost-meter/1.4 (DeepSeek Harness plugin)',
|
|
313
|
+
},
|
|
314
|
+
signal: AbortSignal.timeout(15000),
|
|
315
|
+
})
|
|
316
|
+
} catch (error) {
|
|
317
|
+
lastError = error
|
|
318
|
+
continue // 网络错误:尝试下一个端点变体
|
|
319
|
+
}
|
|
320
|
+
if (response.status === 401 || response.status === 403) {
|
|
321
|
+
const error = new Error(t(locale, 'codingPlanUnauthorized', { provider: meta.label, code: String(response.status) }))
|
|
322
|
+
error.soft = true // Key 无效/无订阅属预期场景,面板中性提示
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
// 带上实际请求 URL:404 往往是端点变更信号,便于定位(issue #17)。
|
|
327
|
+
lastError = new Error(t(locale, 'codingPlanHttp', { provider: meta.label, code: String(response.status), url }))
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
const data = await response.json()
|
|
331
|
+
const windows = parse(data)
|
|
332
|
+
if (windows === null) {
|
|
333
|
+
// 200 但业务失败(如 Z.ai 的错误信封 {code:1001,msg:...}):透出服务端 msg,避免误报「接口结构已变」。
|
|
334
|
+
const envelope = data !== null && typeof data === 'object' && typeof data.code === 'number' && data.code !== 0
|
|
335
|
+
&& typeof (data.msg ?? data.message) === 'string' ? (data.msg ?? data.message) : null
|
|
336
|
+
lastError = envelope !== null
|
|
337
|
+
? new Error(`${meta.label}: ${envelope}`)
|
|
338
|
+
: new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
return { windows, endpoint: url }
|
|
342
|
+
}
|
|
343
|
+
throw lastError ?? new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export { CUSTOM_BALANCE_ADAPTER_ID, emptyCustomBalance, extractByRule, queryCustomBalance } from './custom-balance.js'
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自定义 Provider 余额查询 adapter(用户可配置 HTTP 端点 + 声明式 extract)。
|
|
3
|
+
* 与 coding-plans.js 固定端点 adapter 互补:共用 index.js 侧的 refresh/cache 模式。
|
|
4
|
+
*/
|
|
5
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
6
|
+
|
|
7
|
+
export const CUSTOM_BALANCE_ADAPTER_ID = 'custom'
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} root */
|
|
10
|
+
function getPath(root, path) {
|
|
11
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
12
|
+
let current = root
|
|
13
|
+
for (const segment of path.split('.')) {
|
|
14
|
+
if (current === null || current === undefined || typeof current !== 'object') return undefined
|
|
15
|
+
current = current[segment]
|
|
16
|
+
}
|
|
17
|
+
return current
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} data
|
|
22
|
+
* @param {unknown} rule
|
|
23
|
+
*/
|
|
24
|
+
export function extractByRule(data, rule) {
|
|
25
|
+
if (rule === null || rule === undefined) return null
|
|
26
|
+
if (typeof rule === 'number' && Number.isFinite(rule)) return rule
|
|
27
|
+
if (typeof rule === 'string') {
|
|
28
|
+
const value = getPath(data, rule)
|
|
29
|
+
const num = Number(value)
|
|
30
|
+
return Number.isFinite(num) ? num : (typeof value === 'string' ? value : null)
|
|
31
|
+
}
|
|
32
|
+
if (typeof rule === 'object' && !Array.isArray(rule)) {
|
|
33
|
+
const op = rule.op
|
|
34
|
+
if (op === 'subtract' && Array.isArray(rule.paths)) {
|
|
35
|
+
if (rule.paths.length === 0) return null // 空 paths 防 Reduce of empty array 报错(与 add 的空数组返 0 区分:减法无中性初值)
|
|
36
|
+
const values = rule.paths.map(path => Number(getPath(data, path)))
|
|
37
|
+
if (!values.every(Number.isFinite)) return null
|
|
38
|
+
return values.reduce((acc, value) => acc - value)
|
|
39
|
+
}
|
|
40
|
+
if (op === 'add' && Array.isArray(rule.paths)) {
|
|
41
|
+
const values = rule.paths.map(path => Number(getPath(data, path)))
|
|
42
|
+
if (!values.every(Number.isFinite)) return null
|
|
43
|
+
return values.reduce((acc, value) => acc + value, 0)
|
|
44
|
+
}
|
|
45
|
+
if (typeof rule.path === 'string') return extractByRule(data, rule.path)
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} value
|
|
52
|
+
* @param {import('cordis').Context} ctx
|
|
53
|
+
*/
|
|
54
|
+
async function resolveTemplateString(value, ctx) {
|
|
55
|
+
const pattern = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g
|
|
56
|
+
let out = value
|
|
57
|
+
const names = [...value.matchAll(pattern)].map(match => match[1])
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
let resolved = ''
|
|
60
|
+
const credentials = ctx.get('credentials')
|
|
61
|
+
if (credentials !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
const hit = await credentials.resolve(credentialRef(name))
|
|
64
|
+
if (typeof hit?.value === 'string' && hit.value.length > 0) resolved = hit.value
|
|
65
|
+
} catch {
|
|
66
|
+
// fall through to env
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (resolved.length === 0) resolved = String(process.env[name] ?? '').trim()
|
|
70
|
+
out = out.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g'), resolved)
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Record<string, string>} headers
|
|
77
|
+
* @param {import('cordis').Context} ctx
|
|
78
|
+
*/
|
|
79
|
+
async function resolveHeaders(headers, ctx) {
|
|
80
|
+
const out = {}
|
|
81
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
82
|
+
if (typeof value !== 'string') continue
|
|
83
|
+
out[key] = await resolveTemplateString(value, ctx)
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {import('cordis').Context} ctx
|
|
90
|
+
* @param {Record<string, unknown>} config
|
|
91
|
+
*/
|
|
92
|
+
export async function queryCustomBalance(ctx, config) {
|
|
93
|
+
const custom = config?.customBalance
|
|
94
|
+
if (custom?.enabled !== true) {
|
|
95
|
+
const error = new Error('custom balance disabled')
|
|
96
|
+
error.soft = true
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
const request = custom.request
|
|
100
|
+
if (request === null || typeof request !== 'object' || typeof request.url !== 'string' || request.url.length === 0) {
|
|
101
|
+
throw new Error('customBalance.request.url is required')
|
|
102
|
+
}
|
|
103
|
+
const method = typeof request.method === 'string' ? request.method.toUpperCase() : 'GET'
|
|
104
|
+
const headers = await resolveHeaders(request.headers ?? {}, ctx)
|
|
105
|
+
const init = { method, headers, signal: AbortSignal.timeout(15000) }
|
|
106
|
+
if (method !== 'GET' && method !== 'HEAD' && request.body !== undefined) {
|
|
107
|
+
init.body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body)
|
|
108
|
+
if (!headers['content-type'] && !headers['Content-Type']) {
|
|
109
|
+
init.headers = { ...headers, 'content-type': 'application/json' }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const response = await fetch(request.url, init)
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`custom balance HTTP ${String(response.status)}`)
|
|
115
|
+
}
|
|
116
|
+
const data = await response.json()
|
|
117
|
+
const extract = custom.extract ?? {}
|
|
118
|
+
const remaining = extractByRule(data, extract.remaining)
|
|
119
|
+
if (!Number.isFinite(Number(remaining))) {
|
|
120
|
+
throw new Error('custom balance extract.remaining is missing or not numeric')
|
|
121
|
+
}
|
|
122
|
+
const maxBudget = extract.maxBudget !== undefined ? extractByRule(data, extract.maxBudget) : null
|
|
123
|
+
const spend = extract.spend !== undefined ? extractByRule(data, extract.spend) : null
|
|
124
|
+
const unit = typeof custom.unit === 'string' && custom.unit.length > 0
|
|
125
|
+
? custom.unit
|
|
126
|
+
: (typeof extract.unit === 'string' && extract.unit.length > 0 ? extract.unit : 'USD')
|
|
127
|
+
return {
|
|
128
|
+
label: typeof custom.label === 'string' && custom.label.length > 0 ? custom.label : 'Custom',
|
|
129
|
+
unit,
|
|
130
|
+
remaining: Number(remaining),
|
|
131
|
+
maxBudget: Number.isFinite(Number(maxBudget)) ? Number(maxBudget) : null,
|
|
132
|
+
spend: Number.isFinite(Number(spend)) ? Number(spend) : null,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function emptyCustomBalance() {
|
|
137
|
+
return {
|
|
138
|
+
status: 'off',
|
|
139
|
+
message: '',
|
|
140
|
+
fetchedAt: 0,
|
|
141
|
+
label: '',
|
|
142
|
+
unit: 'USD',
|
|
143
|
+
remaining: 0,
|
|
144
|
+
maxBudget: null,
|
|
145
|
+
spend: null,
|
|
146
|
+
}
|
|
147
|
+
}
|