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.
@@ -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
+ }