dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

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,1006 @@
1
+ /**
2
+ * 网关额度适配器(issue #87)——六家 provider 的纯解析器 + 注册表 + 请求常量。
3
+ *
4
+ * 设计契约(原提案见 issue #87,PR #90 实现):
5
+ * - 本模块是纯函数层:不发网络请求、不读文件系统、不依赖 DSH 运行时;
6
+ * reset 归一在本模块内实现,避免引入 coding-plans.js 与 plan-billing.js 的既有 ESM 环依赖。
7
+ * - 相对 reset 需要"当前时间"时一律用注入时钟 ctx.now()(测试可注入假钟),
8
+ * 绝不偷偷调用 Date.now 造成测试不确定。
9
+ * - percent 语义唯一:**已用**百分比 0–100(用户看到的剩余 = 100 − percent)。
10
+ * 各家 payload 的数字语义不同(小数份额 / 0–100 百分比 / 绝对量),必须逐家
11
+ * 按其真实响应形态转换,严禁共用 coding-plans 的 normalizePercent——它会猜
12
+ * 0–1 还是百分比,语义猜错整个仪表反向。
13
+ * - 付费档 xAI 探活 probe(向对话补全端点 POST 一条真实消息)**故意不实现**:
14
+ * 它会消耗 token,额度表(meter)绝不允许产生任何计费副作用
15
+ * (FORBIDDEN_REQUEST_MARKERS 在 gateway-quotas.js 的 apiCall 里强制拒发)。
16
+ */
17
+
18
+ // 不导入 coding-plans.js:该模块与 plan-billing.js 存在既有 ESM 环依赖,
19
+ // 新 gateway 若静态导入会触发 CODING_PLAN_PROVIDER_IDS 的 TDZ(issue #87)。
20
+ // 这里保留同语义的无依赖 reset 归一,避免把整棵计费模块图拉入 gateway。
21
+ function normalizeResetAt(value) {
22
+ if (typeof value === 'string' && value.length > 0) {
23
+ const parsed = Date.parse(value)
24
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString()
25
+ const numeric = Number(value)
26
+ if (Number.isFinite(numeric) && numeric > 0) {
27
+ const ms = numeric > 1e12 ? numeric : numeric * 1000
28
+ const date = new Date(ms)
29
+ return Number.isFinite(date.getTime()) ? date.toISOString() : ''
30
+ }
31
+ return ''
32
+ }
33
+ const numeric = Number(value)
34
+ if (!Number.isFinite(numeric) || numeric <= 0) return ''
35
+ const ms = numeric > 1e12 ? numeric : numeric * 1000
36
+ const date = new Date(ms)
37
+ return Number.isFinite(date.getTime()) ? date.toISOString() : ''
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // 通用数值/字段 helper(issue #87):自备,不复用 normalizePercent(它会猜方向)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * 大小写/命名风格宽容的字段选取:先精确匹配,再按「去 _/-/空白并小写」归一后匹配。
46
+ * 各家 payload snake_case 为主、camelCase 回落(evidence §8.7),这里统一吸收。
47
+ * 只读自有属性(不沿原型链),找不到返回 undefined。
48
+ */
49
+ export function pickField(obj, ...keys) {
50
+ if (obj === null || typeof obj !== 'object') return undefined
51
+ for (const key of keys) {
52
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return obj[key]
53
+ }
54
+ const normalize = (s) => String(s).toLowerCase().replace(/[_\s-]/g, '')
55
+ const ownKeys = Object.keys(obj)
56
+ for (const key of keys) {
57
+ const target = normalize(key)
58
+ for (const ownKey of ownKeys) {
59
+ if (normalize(ownKey) === target) return obj[ownKey]
60
+ }
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ /** 整数化:数字向下取整、数字字符串取整,其余(含 null/undefined)→ null。 */
66
+ export function toInt(value) {
67
+ if (typeof value === 'number') return Number.isFinite(value) ? Math.floor(value) : null
68
+ if (typeof value === 'string' && value.trim() !== '') {
69
+ const parsed = Number(value.trim())
70
+ return Number.isFinite(parsed) ? Math.floor(parsed) : null
71
+ }
72
+ return null
73
+ }
74
+
75
+ /** 稳定 slug:小写、连续非字母数字(CJK 保留)压成 '-',空串回落 'unknown'。 */
76
+ export function slug(value) {
77
+ const s = String(value ?? '')
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
80
+ .replace(/^-+|-+$/g, '')
81
+ return s || 'unknown'
82
+ }
83
+
84
+ /** 百分比收敛:0–100 截断,保留 1 位小数;非数值 → null。 */
85
+ export function clampPct(value) {
86
+ const n = Number(value)
87
+ if (!Number.isFinite(n)) return null
88
+ return Math.min(100, Math.max(0, Math.round(n * 10) / 10))
89
+ }
90
+
91
+ /**
92
+ * Antigravity remainingFraction 归一(evidence §2.1):
93
+ * - 0–1 的数字按小数份额保留;
94
+ * - 以 % 结尾的字符串 ÷100;
95
+ * - 普通数字 >1(如 28)**不猜方向**,返回 null 由调用方丢弃并告警——
96
+ * 剩余份额还是已用百分比无法从值本身判定,猜错会整个仪表反向。
97
+ */
98
+ export function normalQuotaFraction(value) {
99
+ if (typeof value === 'number') {
100
+ return Number.isFinite(value) && value >= 0 && value <= 1 ? value : null
101
+ }
102
+ if (typeof value === 'string') {
103
+ const trimmed = value.trim()
104
+ if (!trimmed) return null
105
+ if (trimmed.endsWith('%')) {
106
+ const parsed = Number(trimmed.slice(0, -1))
107
+ return Number.isFinite(parsed) ? parsed / 100 : null
108
+ }
109
+ const parsed = Number(trimmed)
110
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null
111
+ }
112
+ return null
113
+ }
114
+
115
+ /** 绝对时刻归一为 ISO 字符串;非法 → '';与 coding-plans 输入语义一致但无依赖。 */
116
+ function absoluteResetAt(value) {
117
+ if (value === undefined || value === null || value === '') return ''
118
+ if (typeof value === 'string') {
119
+ const text = value.trim()
120
+ if (text === '') return ''
121
+ const parsed = Date.parse(text)
122
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString()
123
+ value = text
124
+ }
125
+ const numeric = Number(value)
126
+ if (!Number.isFinite(numeric) || numeric <= 0) return ''
127
+ const date = new Date(numeric > 1e12 ? numeric : numeric * 1000)
128
+ return Number.isFinite(date.getTime()) ? date.toISOString() : ''
129
+ }
130
+
131
+ /** 相对秒数 → 绝对 ISO(基于注入时钟);非法 → ''。 */
132
+ function relativeResetAt(seconds, nowMs) {
133
+ const n = typeof seconds === 'number' ? seconds : typeof seconds === 'string' && seconds.trim() !== '' ? Number(seconds.trim()) : NaN
134
+ if (!Number.isFinite(n) || n < 0) return ''
135
+ return new Date(nowMs + n * 1000).toISOString()
136
+ }
137
+
138
+ /** 解析器抛错统一携带 PROVIDER_PARSE_ERROR 码(外层按此分类,不重试)。 */
139
+ function parseError(message) {
140
+ const err = new Error(message)
141
+ err.code = 'PROVIDER_PARSE_ERROR'
142
+ return err
143
+ }
144
+
145
+ /** 统一窗口形状:{ id, label, percent(已用 0–100), resetsAt, periodHours, scope }。 */
146
+ function makeWindow(id, label, percent, resetsAt, periodHours, scope) {
147
+ return { id, label, percent, resetsAt, periodHours, scope }
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // 请求常量(evidence §2–§7,逐字照抄;$TOKEN$ 为宿主替换的 token 占位符)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * 顺序即回退链:queryApiAccount 对非 xai provider 首个成功即 break,
156
+ * 因此首选端点必须是全量端点。
157
+ * 实测(3 账号 x 3 轮,9/9 稳定):
158
+ * daily / sandbox -> 仅 "Claude and GPT models"(3p-weekly, 3p-5h)
159
+ * cloudcode-pa -> "Gemini Models"(gemini-weekly, gemini-5h) + 第三方组
160
+ * 原顺序把 daily 放首位,200 成功后即 break,prod 永不触达 => Gemini 额度恒丢失。
161
+ */
162
+ export const ANTIGRAVITY_QUOTA_URLS = [
163
+ 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary',
164
+ 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary',
165
+ 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary',
166
+ ]
167
+
168
+ export const ANTIGRAVITY_MODELS_URLS = [
169
+ 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
170
+ 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
171
+ ]
172
+
173
+ const ANTIGRAVITY_USER_AGENT = 'antigravity/2.24.0 (windows; amd64)'
174
+
175
+ export const ANTIGRAVITY_REQUEST_HEADERS = {
176
+ Authorization: 'Bearer $TOKEN$',
177
+ 'Content-Type': 'application/json',
178
+ 'User-Agent': ANTIGRAVITY_USER_AGENT,
179
+ }
180
+
181
+ export function antigravityProjectId(account) {
182
+ const raw = [account?.projectId, account?.project_id].find(value => typeof value === 'string' && value.trim())
183
+ const project = raw?.trim() ?? ''
184
+ return project === 'aicode-consumers' ? '' : project
185
+ }
186
+
187
+ export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
188
+
189
+ export const CLAUDE_REQUEST_HEADERS = {
190
+ Authorization: 'Bearer $TOKEN$',
191
+ 'Content-Type': 'application/json',
192
+ 'anthropic-beta': 'oauth-2025-04-20',
193
+ }
194
+
195
+ export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'
196
+
197
+ export const CODEX_REQUEST_HEADERS = {
198
+ Authorization: 'Bearer $TOKEN$',
199
+ 'Content-Type': 'application/json',
200
+ 'User-Agent': 'codex-tui/0.149.1 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.149.1)',
201
+ }
202
+
203
+ /** Codex usage requires the account-scoped header from the auth file's JWT claims. */
204
+ function codexRequestHeaders(account = {}) {
205
+ const accountId = typeof account?.chatgptAccountId === 'string'
206
+ ? account.chatgptAccountId.trim()
207
+ : typeof account?.chatgpt_account_id === 'string' ? account.chatgpt_account_id.trim() : ''
208
+ return accountId ? { ...CODEX_REQUEST_HEADERS, 'Chatgpt-Account-Id': accountId } : { ...CODEX_REQUEST_HEADERS }
209
+ }
210
+
211
+ export const KIMI_USAGE_URL = 'https://api.kimi.com/coding/v1/usages'
212
+
213
+ export const KIMI_REQUEST_HEADERS = {
214
+ Authorization: 'Bearer $TOKEN$',
215
+ }
216
+
217
+ export const XAI_BILLING_WEEKLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'
218
+ export const XAI_BILLING_MONTHLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing'
219
+
220
+ const XAI_GROK_USER_AGENT = 'grok-pager/0.2.91 grok-shell/0.2.91 (macos; aarch64)'
221
+
222
+ export const XAI_REQUEST_HEADERS = {
223
+ Authorization: 'Bearer $TOKEN$',
224
+ 'x-xai-token-auth': 'xai-grok-cli',
225
+ 'x-grok-client-version': '0.2.91',
226
+ accept: '*/*',
227
+ 'user-agent': XAI_GROK_USER_AGENT,
228
+ }
229
+
230
+ export const XAI_API_ME_URL = 'https://api.x.ai/v1/me'
231
+ export const XAI_API_REQUEST_HEADERS = {
232
+ Authorization: 'Bearer $TOKEN$',
233
+ accept: 'application/json',
234
+ }
235
+
236
+ function xaiBillingRequests(account = {}) {
237
+ const userId = typeof account?.userId === 'string' ? account.userId.trim() : ''
238
+ const headers = userId ? { ...XAI_REQUEST_HEADERS, 'x-userid': userId } : { ...XAI_REQUEST_HEADERS }
239
+ return [
240
+ { method: 'GET', url: XAI_BILLING_WEEKLY_URL, headers },
241
+ { method: 'GET', url: XAI_BILLING_MONTHLY_URL, headers },
242
+ ]
243
+ }
244
+
245
+ // WorkBuddy 插件管理面只读路由(transport 为 plugin-management,由宿主拼 baseURL)。
246
+ export const WORKBUDDY_CREDITS_PATH = '/v0/management/plugins/workbuddy/credits'
247
+
248
+ /**
249
+ * 额度表**绝对禁止**触碰的请求特征(副作用端点):
250
+ * - Codex 重置积分的「消费」端点会烧掉一枚重置积分;
251
+ * - 对话补全端点会消耗真实 token(xAI 付费探活即属此类)。
252
+ * 宿主层在发出任何请求前必须逐一比对本表,命中即拒发。
253
+ * 注:字符串拼接构造以保持源码不含完整字面量(verify 负面断言扫描源文件)。
254
+ */
255
+ export const FORBIDDEN_REQUEST_MARKERS = [
256
+ ['rate-limit-reset-credits', '/consume'].join(''),
257
+ ['chat', '/completions'].join(''),
258
+ ]
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Antigravity(evidence §2.1)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /** window 关键字 → 窗口元数据;未知窗口用稳定 slug,时长不猜。 */
265
+ function antigravityWindowMeta(rawWindow) {
266
+ const key = String(rawWindow ?? '').trim().toLowerCase()
267
+ if (key === '5h' || key === 'five-hour' || key === 'five_hour') {
268
+ return { id: 'five-hour', label: '5h', periodHours: 5 }
269
+ }
270
+ if (key === 'weekly' || key === 'week') {
271
+ return { id: 'weekly', label: 'Weekly', periodHours: 168 }
272
+ }
273
+ return { id: slug(key), label: String(rawWindow ?? 'unknown'), periodHours: null }
274
+ }
275
+
276
+ /**
277
+ * 解析 Antigravity 配额摘要:groups[].buckets[]。
278
+ * - remainingFraction 是 0–1 份额(或 "72%" 字符串);percent = (1 − f) × 100(已用方向);
279
+ * - >1 的普通数字直接丢弃并告警(不猜,见 normalQuotaFraction);
280
+ * - 窗口 id = groupId:windowId,跨 group 同名窗口互不覆盖;
281
+ * - 空 group 丢弃;整包零有效 bucket → PROVIDER_PARSE_ERROR。
282
+ */
283
+ export function parseAntigravityQuota(payload, ctx = {}) {
284
+ const warnings = []
285
+ const windows = []
286
+ const seen = new Set()
287
+ const geminiOnly = ctx?.geminiOnly === true
288
+ let filteredValidGroup = false
289
+ let keptGroup = false
290
+ const groups = Array.isArray(payload?.groups) ? payload.groups : []
291
+ for (let gi = 0; gi < groups.length; gi++) {
292
+ const group = groups[gi]
293
+ if (group === null || typeof group !== 'object' || Array.isArray(group)) continue
294
+ const explicitId = pickField(group, 'groupId', 'group_id', 'id')
295
+ const rawGroupTitle = pickField(group, 'displayName', 'display_name', 'name') ?? explicitId
296
+ let groupTitle = rawGroupTitle ? String(rawGroupTitle).replace(/\s+models$/i, '').trim() : `group-${gi}`
297
+ if (groupTitle.toLowerCase().includes('claude') && groupTitle.toLowerCase().includes('gpt')) groupTitle = 'Claude / GPT'
298
+ // 只保留明确标识为 Gemini 的组;未知/第三方组不按原生额度展示。
299
+ if (geminiOnly && !/gemini/i.test(`${rawGroupTitle ?? ''} ${explicitId ?? ''}`)) {
300
+ filteredValidGroup ||= Array.isArray(group.buckets) && group.buckets.some(bucket =>
301
+ bucket && normalQuotaFraction(pickField(bucket, 'remainingFraction', 'remaining_fraction')) !== null)
302
+ warnings.push('antigravity: group hidden by geminiOnly')
303
+ continue
304
+ }
305
+ keptGroup = true
306
+ const groupId = slug(explicitId ?? groupTitle)
307
+ const buckets = Array.isArray(group.buckets) ? group.buckets : []
308
+ let added = 0
309
+ for (const bucket of buckets) {
310
+ if (bucket === null || typeof bucket !== 'object' || Array.isArray(bucket)) continue
311
+ const fraction = normalQuotaFraction(pickField(bucket, 'remainingFraction', 'remaining_fraction'))
312
+ if (fraction === null) {
313
+ // 剩余份额 >1 或非法:方向不明,丢弃并告警(绝不猜成已用百分比或小数)
314
+ warnings.push(`antigravity: ${groupId} 的 remainingFraction 非法,已丢弃该 bucket`)
315
+ continue
316
+ }
317
+ const meta = antigravityWindowMeta(pickField(bucket, 'window'))
318
+ const id = `${groupId}:${meta.id}`
319
+ if (seen.has(id)) continue // 同名窗口不覆盖,先到先得
320
+ seen.add(id)
321
+ const percent = clampPct((1 - fraction) * 100)
322
+ windows.push(makeWindow(
323
+ id,
324
+ `${groupTitle} · ${meta.label}`,
325
+ percent,
326
+ absoluteResetAt(pickField(bucket, 'resetTime', 'reset_time', 'resetTime')),
327
+ meta.periodHours,
328
+ 'account',
329
+ ))
330
+ added++
331
+ }
332
+ if (added === 0) warnings.push(`antigravity: group ${groupId} 无有效 bucket,已丢弃`)
333
+ }
334
+ const models = payload?.models !== null && typeof payload?.models === 'object' && !Array.isArray(payload.models) ? payload.models : null
335
+ if (models) {
336
+ const hasGeminiWindow = windows.some(w => /gemini/i.test(w.id) || /gemini/i.test(w.label))
337
+ if (!hasGeminiWindow) {
338
+ for (const [modelId, info] of Object.entries(models)) {
339
+ if (!/gemini/i.test(modelId) || !info || typeof info !== 'object') continue
340
+ const rawQuota = pickField(info, 'quotaInfo', 'quota_info')
341
+ const quota = rawQuota !== null && typeof rawQuota === 'object' && !Array.isArray(rawQuota) ? rawQuota : null
342
+ if (!quota) continue
343
+ const fraction = normalQuotaFraction(pickField(quota, 'remainingFraction', 'remaining_fraction'))
344
+ if (fraction === null) continue
345
+ const id = 'gemini-model:' + slug(modelId)
346
+ if (seen.has(id)) continue
347
+ seen.add(id)
348
+ keptGroup = true
349
+ const percent = clampPct((1 - fraction) * 100)
350
+ windows.push(makeWindow(
351
+ id,
352
+ typeof info.displayName === 'string' && info.displayName.trim() ? info.displayName.trim().slice(0, 160) : modelId,
353
+ percent,
354
+ absoluteResetAt(pickField(quota, 'resetTime', 'reset_time')),
355
+ null,
356
+ 'model',
357
+ ))
358
+ }
359
+ }
360
+ }
361
+ if (windows.length === 0 && (!filteredValidGroup || keptGroup)) {
362
+ throw parseError('antigravity: payload 中没有任何可解析的配额 bucket')
363
+ }
364
+ return { windows, warnings }
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Claude(evidence §6)
369
+ // ---------------------------------------------------------------------------
370
+
371
+ const CLAUDE_USAGE_WINDOW_KEYS = [
372
+ { key: 'five_hour', id: 'five-hour', label: '5 小时' },
373
+ { key: 'seven_day', id: 'seven-day', label: '7 天' },
374
+ { key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', label: '7 天 OAuth Apps' },
375
+ { key: 'seven_day_opus', id: 'seven-day-opus', label: '7 天 Opus' },
376
+ { key: 'seven_day_sonnet', id: 'seven-day-sonnet', label: '7 天 Sonnet' },
377
+ { key: 'seven_day_cowork', id: 'seven-day-cowork', label: '7 天 Cowork' },
378
+ { key: 'iguana_necktie', id: 'seven-day-fable', label: '7 天 Fable' },
379
+ ]
380
+
381
+ /** Claude 周期不写在 payload 里,由 key 推导:five_hour 是滚动 5 小时,其余全按 7 天。 */
382
+ function claudePeriodHours(windowKey) {
383
+ return windowKey === 'five_hour' ? 5 : 168
384
+ }
385
+
386
+ /**
387
+ * 解析 Claude usage payload(evidence §6.2–6.3):
388
+ * - utilization 本身就是 0–100 已用百分比,原样收敛,绝不换算;
389
+ * - 没有 utilization 自有属性的窗口跳过(payload 常只回部分窗口);
390
+ * - limits[] 中 weekly_scoped + 模型名 fable / fable 5 → 动态 Fable 窗口,
391
+ * 此时静态 iguana_necktie 键弃用(避免同窗口出现两份);
392
+ * - 一个窗口都解析不出来 → PROVIDER_PARSE_ERROR。
393
+ */
394
+ export function parseClaudeUsage(payload) {
395
+ const windows = []
396
+ const source = payload === null || typeof payload !== 'object' ? {} : payload
397
+ for (const entry of CLAUDE_USAGE_WINDOW_KEYS) {
398
+ const window = source[entry.key]
399
+ if (window === null || typeof window !== 'object' || Array.isArray(window)) continue
400
+ if (!Object.prototype.hasOwnProperty.call(window, 'utilization')) continue
401
+ const percent = clampPct(window.utilization)
402
+ if (percent === null) continue
403
+ windows.push(makeWindow(
404
+ entry.id,
405
+ entry.label,
406
+ percent,
407
+ absoluteResetAt(pickField(window, 'resets_at', 'resetsAt')),
408
+ claudePeriodHours(entry.key),
409
+ 'account',
410
+ ))
411
+ }
412
+ const limits = Array.isArray(source.limits) ? source.limits : []
413
+ const fableCandidates = []
414
+ for (const limit of limits) {
415
+ if (limit === null || typeof limit !== 'object' || Array.isArray(limit)) continue
416
+ const kind = String(pickField(limit, 'kind') ?? '').trim().toLowerCase()
417
+ if (kind !== 'weekly_scoped') continue
418
+ const model = pickField(pickField(limit, 'scope') ?? {}, 'model')
419
+ const modelName = String(pickField(model ?? {}, 'display_name', 'displayName') ?? '').trim().toLowerCase()
420
+ if (modelName !== 'fable' && modelName !== 'fable 5') continue
421
+ const percent = clampPct(pickField(limit, 'percent'))
422
+ if (percent === null) continue
423
+ fableCandidates.push({ limit, percent })
424
+ }
425
+ const fable = fableCandidates.find(({ limit }) => pickField(limit, 'is_active', 'isActive') === true)
426
+ ?? fableCandidates[0]
427
+ const fableFound = fable !== undefined
428
+ if (fableFound) {
429
+ const { limit, percent } = fable
430
+ windows.push(makeWindow(
431
+ 'seven-day-fable',
432
+ '7 天 Fable',
433
+ percent,
434
+ absoluteResetAt(pickField(limit, 'resets_at', 'resetsAt')),
435
+ claudePeriodHours('seven_day'),
436
+ 'model',
437
+ ))
438
+ }
439
+ if (fableFound) {
440
+ // 动态 Fable limit 存在时,静态 iguana_necktie 键(同为 seven-day-fable)不再保留
441
+ for (let i = windows.length - 1; i >= 0; i--) {
442
+ if (windows[i].id === 'seven-day-fable' && windows[i].scope === 'account') windows.splice(i, 1)
443
+ }
444
+ }
445
+ if (windows.length === 0) {
446
+ throw parseError('claude: usage payload 未解析出任何窗口')
447
+ }
448
+ return { windows, warnings: [] }
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Codex(evidence §3)
453
+ // ---------------------------------------------------------------------------
454
+
455
+ const FIVE_HOUR_SECONDS = 18000
456
+ const WEEK_SECONDS = 604800
457
+ const MIN_MONTH_SECONDS = 28 * 24 * 60 * 60 // 2419200
458
+ const MAX_MONTH_SECONDS = 31 * 24 * 60 * 60 // 2678400
459
+
460
+ /** 按 limit_window_seconds 分类窗口;无法判定返回 null(由调用方走顺序兜底)。 */
461
+ function codexWindowMeta(seconds) {
462
+ const value = typeof seconds === 'number'
463
+ ? seconds
464
+ : typeof seconds === 'string' && seconds.trim() !== '' ? Number(seconds.trim()) : NaN
465
+ if (!Number.isFinite(value) || value <= 0) return null
466
+ if (value === FIVE_HOUR_SECONDS) return { id: 'five-hour', label: '5h', periodHours: 5 }
467
+ if (value === WEEK_SECONDS) return { id: 'weekly', label: '周', periodHours: 168 }
468
+ if (value >= MIN_MONTH_SECONDS && value <= MAX_MONTH_SECONDS) {
469
+ return { id: 'monthly', label: '月度', periodHours: Math.round(value / 3600) }
470
+ }
471
+ return { id: slug(`win-${value}`), label: `${Math.round(value / 3600)} 小时`, periodHours: Math.round(value / 3600) }
472
+ }
473
+
474
+ function codexWindowMetaOrFallback(window, position) {
475
+ return codexWindowMeta(pickField(window, 'limit_window_seconds', 'limitWindowSeconds'))
476
+ ?? (position === 0
477
+ ? { id: 'primary', label: '主窗口', periodHours: null }
478
+ : { id: 'secondary', label: '次窗口', periodHours: null })
479
+ }
480
+
481
+ /**
482
+ * 解析 Codex usage payload(evidence §3.2–3.6)。
483
+ * 三组限额:rate_limit(主,scope=account)、code_review_rate_limit、additional_rate_limits[]
484
+ * (后两者 scope=model);每组取 primary_window/secondary_window 两个窗口。
485
+ * - used_percent 本身是 0–100 已用百分比,原样收敛;
486
+ * - used_percent 缺失且 limit_reached(或 allowed===false)→ 合成 100;
487
+ * - used_percent 缺失且无任何信号 → 跳过该窗口并告警;
488
+ * - reset 绝对 reset_at 优先,回落 reset_after_seconds(注入时钟 ctx.now);
489
+ * - 窗口 id 加组前缀:primary:/code-review:/additional-<i>-<slug>:。
490
+ */
491
+ export function parseCodexUsage(payload, ctx = {}) {
492
+ const now = typeof ctx?.now === 'function' ? ctx.now : Date.now
493
+ const warnings = []
494
+ const windows = []
495
+ const seen = new Set()
496
+
497
+ const pushWindow = (prefix, scope, window, position, groupLabel) => {
498
+ if (window === null || typeof window !== 'object' || Array.isArray(window)) return
499
+ const meta = codexWindowMetaOrFallback(window, position)
500
+ const id = `${prefix}:${meta.id}`
501
+ if (seen.has(id)) return
502
+ let percent = clampPct(pickField(window, 'used_percent', 'usedPercent'))
503
+ if (percent === null) {
504
+ const limitReached = pickField(window, 'limit_reached', 'limitReached') === true
505
+ const allowed = pickField(window, 'allowed')
506
+ if (limitReached || allowed === false) {
507
+ percent = 100 // 限额已触发,used_percent 缺失时合成 100(evidence §3.3)
508
+ } else {
509
+ warnings.push(`codex: ${id} 缺少 used_percent 且无限额信号,窗口跳过`)
510
+ return
511
+ }
512
+ }
513
+ const resetAt = absoluteResetAt(pickField(window, 'reset_at', 'resetAt'))
514
+ || relativeResetAt(pickField(window, 'reset_after_seconds', 'resetAfterSeconds'), now())
515
+ seen.add(id)
516
+ // 组标签为空时直接用窗口标签(主限额组不叠加前缀,窄栏标签列放不下会竖排换行)。
517
+ windows.push(makeWindow(id, groupLabel ? `${groupLabel} · ${meta.label}` : meta.label, percent, resetAt, meta.periodHours, scope))
518
+ }
519
+
520
+ const source = payload === null || typeof payload !== 'object' ? {} : payload
521
+
522
+ const mainGroup = pickField(source, 'rate_limit', 'rateLimit')
523
+ if (mainGroup !== null && typeof mainGroup === 'object' && !Array.isArray(mainGroup)) {
524
+ pushWindow('primary', 'account', pickField(mainGroup, 'primary_window', 'primaryWindow'), 0, '')
525
+ pushWindow('primary', 'account', pickField(mainGroup, 'secondary_window', 'secondaryWindow'), 1, '')
526
+ }
527
+
528
+ const codeReviewGroup = pickField(source, 'code_review_rate_limit', 'codeReviewRateLimit')
529
+ if (codeReviewGroup !== null && typeof codeReviewGroup === 'object' && !Array.isArray(codeReviewGroup)) {
530
+ pushWindow('code-review', 'model', pickField(codeReviewGroup, 'primary_window', 'primaryWindow'), 0, 'Code Review')
531
+ pushWindow('code-review', 'model', pickField(codeReviewGroup, 'secondary_window', 'secondaryWindow'), 1, 'Code Review')
532
+ }
533
+
534
+ const additional = pickField(source, 'additional_rate_limits', 'additionalRateLimits')
535
+ if (Array.isArray(additional)) {
536
+ for (let i = 0; i < additional.length; i++) {
537
+ const item = additional[i]
538
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
539
+ const name = pickField(item, 'limit_name', 'limitName', 'metered_feature', 'meteredFeature') ?? `additional-${i}`
540
+ // 容错:限额数据可能在 item.rate_limit 里,也可能直接摊在 item 上
541
+ const group = pickField(item, 'rate_limit', 'rateLimit')
542
+ const holder = group !== null && typeof group === 'object' && !Array.isArray(group) ? group : item
543
+ const prefix = `additional-${i}-${slug(name)}`
544
+ pushWindow(prefix, 'model', pickField(holder, 'primary_window', 'primaryWindow'), 0, String(name))
545
+ pushWindow(prefix, 'model', pickField(holder, 'secondary_window', 'secondaryWindow'), 1, String(name))
546
+ }
547
+ }
548
+
549
+ const planType = String(pickField(source, 'plan_type', 'planType') ?? '').trim().toLowerCase()
550
+ if (windows.length === 0) {
551
+ throw parseError('codex: usage payload 未解析出任何窗口')
552
+ }
553
+ return { windows, plan: planType, warnings }
554
+ }
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // Kimi(evidence §4)
558
+ // ---------------------------------------------------------------------------
559
+
560
+ /**
561
+ * protobuf 风格 timeUnit 归一:剥掉 TIME_UNIT_ 前缀后识别;
562
+ * 缺失/UNKNOWN 一律回落**分钟**(evidence §4.3 明确:未知单位不是秒)。
563
+ */
564
+ function normalizeKimiTimeUnit(rawTimeUnit) {
565
+ const unit = typeof rawTimeUnit === 'string'
566
+ ? rawTimeUnit.trim().toUpperCase().replace(/^TIME_UNIT_/, '')
567
+ : ''
568
+ if (unit === 'SECONDS' || unit === 'SECOND') return 'second'
569
+ if (unit === 'HOURS' || unit === 'HOUR') return 'hour'
570
+ if (unit === 'DAYS' || unit === 'DAY') return 'day'
571
+ if (unit === 'WEEKS' || unit === 'WEEK') return 'week'
572
+ return 'minute' // MINUTE(S)、空串、MISSING、UNKNOWN 等全部按分钟
573
+ }
574
+
575
+ /** 无 duration 时按 label 关键词猜周期(daily→24 等);猜不出返回 null。 */
576
+ function kimiKeywordHours(label) {
577
+ const key = String(label ?? '').toLowerCase()
578
+ if (key.includes('daily') || key.includes('day')) return 24
579
+ if (key.includes('weekly') || key.includes('week')) return 168
580
+ if (key.includes('monthly') || key.includes('month')) return 720
581
+ if (key.includes('5h') || key.includes('hour')) return 5
582
+ return null
583
+ }
584
+
585
+ function kimiPeriodHours(duration, timeUnit, label) {
586
+ const value = Number(duration)
587
+ if (!Number.isFinite(value) || value <= 0) return kimiKeywordHours(label)
588
+ const unit = normalizeKimiTimeUnit(timeUnit)
589
+ if (unit === 'second') return value / 3600
590
+ if (unit === 'hour') return value
591
+ if (unit === 'day') return value * 24
592
+ if (unit === 'week') return value * 168
593
+ return value / 60 // minute(含未知/缺失):duration 按分钟换算成小时
594
+ }
595
+
596
+ /** Kimi reset:绝对键优先,回落相对秒(reset_in/resetIn/ttl,基于注入时钟)。 */
597
+ function kimiResetAt(data, nowMs) {
598
+ const absolute = absoluteResetAt(pickField(data, 'reset_at', 'resetAt', 'reset_time', 'resetTime'))
599
+ if (absolute !== '') return absolute
600
+ for (const key of ['reset_in', 'resetIn', 'ttl']) {
601
+ const relative = relativeResetAt(pickField(data, key), nowMs)
602
+ if (relative !== '') return relative
603
+ }
604
+ return ''
605
+ }
606
+
607
+ /**
608
+ * 解析 Kimi usages payload(evidence §4.2–4.3)。
609
+ * - limits[] 每行取 limit/used/remaining(**绝对量**,toInt 向下取整);
610
+ * used 缺失时派生 used = limit − remaining;used 与 limit 双空的行整行丢弃;
611
+ * - limit>0 才产出窗口(percent = used/limit×100),否则告警不产窗口;
612
+ * - 周期:window.duration + timeUnit(缺失/未知按分钟);完全没 duration 按关键词兜底;
613
+ * - 顶层 usage{} 作为额外一行(周限额);无网络、无副作用。
614
+ */
615
+ export function parseKimiUsage(payload, ctx = {}) {
616
+ const now = typeof ctx?.now === 'function' ? ctx.now : Date.now
617
+ const warnings = []
618
+ const windows = []
619
+ const source = payload === null || typeof payload !== 'object' ? {} : payload
620
+
621
+ const addRow = (data, windowSource, fallbackLabel) => {
622
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return
623
+ const limit = toInt(pickField(data, 'limit'))
624
+ let used = toInt(pickField(data, 'used'))
625
+ if (used === null) {
626
+ const remaining = toInt(pickField(data, 'remaining'))
627
+ if (remaining !== null && limit !== null) used = limit - remaining
628
+ }
629
+ if (used === null && limit === null) return // 双空行整行丢弃(evidence §4.3 逐字)
630
+ const label = String(pickField(data, 'name', 'title', 'scope') ?? fallbackLabel ?? '限额')
631
+ const duration = pickField(windowSource, 'duration') ?? pickField(data, 'duration')
632
+ const timeUnit = pickField(windowSource, 'timeUnit', 'time_unit') ?? pickField(data, 'timeUnit', 'time_unit')
633
+ if (limit !== null && limit > 0 && used !== null) {
634
+ windows.push(makeWindow(
635
+ `kimi:${slug(label)}`,
636
+ label,
637
+ clampPct((used / limit) * 100),
638
+ kimiResetAt(data, now()),
639
+ kimiPeriodHours(duration, timeUnit, label),
640
+ 'account',
641
+ ))
642
+ } else {
643
+ warnings.push(`kimi: 行「${label}」缺少 limit>0 或可派生的 used,未产出窗口`)
644
+ }
645
+ }
646
+
647
+ const limits = Array.isArray(source.limits) ? source.limits : []
648
+ for (const item of limits) {
649
+ if (item === null || typeof item !== 'object') continue
650
+ const detail = pickField(item, 'detail')
651
+ const data = detail !== null && typeof detail === 'object' && !Array.isArray(detail) ? detail : item
652
+ const fallbackLabel = pickField(item, 'name', 'title', 'scope')
653
+ const windowSource = pickField(item, 'window')
654
+ addRow(data, windowSource !== null && typeof windowSource === 'object' ? windowSource : null, fallbackLabel)
655
+ }
656
+
657
+ const usage = pickField(source, 'usage')
658
+ if (usage !== null && typeof usage === 'object' && !Array.isArray(usage)) {
659
+ addRow(usage, null, '周限额 Weekly limit') // 顶层汇总单出一行
660
+ }
661
+
662
+ if (windows.length === 0) throw parseError('kimi: payload 中没有任何可解析的 usage window')
663
+ return { windows, warnings }
664
+ }
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // xAI(免费档 billing,同路径双查询合并)(evidence §5)
668
+ // ---------------------------------------------------------------------------
669
+
670
+ /** 金额字段可能是 {val:n} 包装:解包后取数(evidence §5.6)。 */
671
+ function xaiCents(value) {
672
+ if (value === undefined || value === null) return null
673
+ if (typeof value === 'object' && !Array.isArray(value)) return toInt(pickField(value, 'val'))
674
+ return toInt(value)
675
+ }
676
+
677
+ /** 单份 billing config 的摘要。hasWeeklyData 判定依据 evidence §5.3。 */
678
+ function xaiConfigSummary(config) {
679
+ const empty = {
680
+ present: false, hasWeeklyData: false, creditUsagePercent: null, derivedPercent: null,
681
+ onDemandPercent: null, products: [], period: null, periodType: 'unknown',
682
+ }
683
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) return empty
684
+ const creditUsagePercent = clampPct(pickField(config, 'creditUsagePercent', 'credit_usage_percent'))
685
+ const monthlyLimitCents = xaiCents(pickField(config, 'monthlyLimit', 'monthly_limit'))
686
+ const usedCents = xaiCents(pickField(config, 'used'))
687
+ // 月度已用百分比是**客户端派生**(min(used, limit)/limit×100),不是服务端给的
688
+ const includedUsedCents = usedCents === null
689
+ ? null
690
+ : monthlyLimitCents !== null && monthlyLimitCents > 0 ? Math.min(usedCents, monthlyLimitCents) : usedCents
691
+ const derivedPercent = monthlyLimitCents !== null && monthlyLimitCents > 0 && includedUsedCents !== null
692
+ ? clampPct((includedUsedCents / monthlyLimitCents) * 100)
693
+ : null
694
+ const onDemandCapCents = xaiCents(pickField(config, 'onDemandCap', 'on_demand_cap'))
695
+ const derivedOnDemandUsedCents = usedCents !== null && monthlyLimitCents !== null
696
+ ? Math.max(0, usedCents - monthlyLimitCents)
697
+ : null
698
+ const onDemandUsedCents = xaiCents(pickField(config, 'onDemandUsed', 'on_demand_used')) ?? derivedOnDemandUsedCents
699
+ const onDemandPercent = onDemandCapCents !== null && onDemandCapCents > 0 && onDemandUsedCents !== null
700
+ ? clampPct((onDemandUsedCents / onDemandCapCents) * 100)
701
+ : null
702
+ const productsRaw = pickField(config, 'productUsage', 'product_usage')
703
+ const products = Array.isArray(productsRaw)
704
+ ? productsRaw
705
+ .map((row, i) => {
706
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return null
707
+ const percent = clampPct(pickField(row, 'usagePercent', 'usage_percent'))
708
+ if (percent === null) return null
709
+ const name = pickField(row, 'product', 'name') ?? `product-${i}`
710
+ return makeWindow(`product-${i}-${slug(name)}`, String(name), percent, '', null, 'product')
711
+ })
712
+ .filter(Boolean)
713
+ : []
714
+ const period = pickField(config, 'currentPeriod', 'current_period')
715
+ const periodTypeRaw = String(pickField(period ?? {}, 'type') ?? pickField(config, 'periodType', 'period_type') ?? '').toLowerCase()
716
+ const periodType = periodTypeRaw.includes('weekly')
717
+ ? 'weekly'
718
+ : periodTypeRaw.includes('monthly') ? 'monthly' : 'unknown'
719
+ const hasWeeklyData = creditUsagePercent !== null || periodType === 'weekly' || products.length > 0
720
+ const present = creditUsagePercent !== null || monthlyLimitCents !== null || usedCents !== null
721
+ || onDemandCapCents !== null || products.length > 0 || period !== null && typeof period === 'object'
722
+ return {
723
+ present, hasWeeklyData, creditUsagePercent, derivedPercent, onDemandPercent,
724
+ products, period: period !== null && typeof period === 'object' ? period : null, periodType,
725
+ }
726
+ }
727
+
728
+ /** 周期起止(自身时钟)→ { resetsAt, periodHours };跨度才算小时,绝不假设周/月。 */
729
+ function xaiPeriodBounds(summary) {
730
+ const period = summary?.period
731
+ if (period === null || period === undefined) return { resetsAt: '', periodHours: null }
732
+ const endRaw = pickField(period, 'end') ?? null
733
+ const startRaw = pickField(period, 'start') ?? null
734
+ const resetsAt = absoluteResetAt(endRaw)
735
+ const endMs = resetsAt === '' ? null : Date.parse(resetsAt)
736
+ const startAt = absoluteResetAt(startRaw)
737
+ const startMs = startAt === '' ? null : Date.parse(startAt)
738
+ const periodHours = endMs !== null && startMs !== null && endMs > startMs
739
+ ? Math.round(((endMs - startMs) / 3_600_000) * 10) / 10
740
+ : null
741
+ return { resetsAt, periodHours }
742
+ }
743
+
744
+ /**
745
+ * 解析 xAI billing 摘要:summaries = { weekly?: config, monthly?: config }。
746
+ * 两个端点是同一路径的并行查询(?format=credits 只是查询参数差异),取回后合并:
747
+ * - weekly 为主:period 类型/起止一律以 weekly 侧为准;字段级 primary ?? fallback;
748
+ * - **两个端点的时钟绝不混用**——reset 与 periodHours 只取自提供该百分比的同一份 config;
749
+ * - weekly creditUsagePercent 服务端直供原样收敛;monthly 百分比由 cents 派生;
750
+ * - 产品行 scope='product';on-demand 有 cap>0 时派生独立窗口。
751
+ * 注:付费档探活 probe 会消耗真实 token,**故意不实现**——额度表绝不产生计费副作用。
752
+ */
753
+ export function parseXaiBilling(summaries, ctx = {}) {
754
+ void ctx // 周期只用绝对起止,不涉及相对秒,时钟不参与
755
+ const warnings = []
756
+ const weekly = xaiConfigSummary(summaries?.weekly ?? null)
757
+ const monthly = xaiConfigSummary(summaries?.monthly ?? null)
758
+
759
+ const windows = []
760
+ if (!weekly.present && !monthly.present) {
761
+ return { windows, warnings: ['xai: billing 响应里没有可识别的周期/用量字段'] }
762
+ }
763
+
764
+ // 计费窗口:weekly 有数据则用 weekly 的服务端百分比(缺失回落 weekly 派生值),
765
+ // 否则用 monthly 派生值;reset/periodHours 与百分比同源,不混时钟。
766
+ const weeklyWins = weekly.hasWeeklyData
767
+ const billingPercent = weeklyWins
768
+ ? (weekly.creditUsagePercent ?? weekly.derivedPercent ?? monthly.derivedPercent)
769
+ : (monthly.derivedPercent ?? weekly.creditUsagePercent)
770
+ const billingSummary = weeklyWins && (weekly.period !== null || weekly.creditUsagePercent !== null)
771
+ ? weekly
772
+ : monthly.period !== null ? monthly : weekly
773
+ const bounds = xaiPeriodBounds(billingSummary)
774
+ const billingPeriodType = weeklyWins && weekly.periodType !== 'unknown' ? weekly.periodType : billingSummary.periodType
775
+ if (billingPercent !== null) {
776
+ const id = billingPeriodType === 'weekly'
777
+ ? 'billing-weekly'
778
+ : billingPeriodType === 'monthly' ? 'billing-monthly' : 'billing'
779
+ const label = billingPeriodType === 'weekly'
780
+ ? '计费周期(周)'
781
+ : billingPeriodType === 'monthly' ? '计费周期(月)' : '计费周期'
782
+ windows.push(makeWindow(id, label, billingPercent, bounds.resetsAt, bounds.periodHours, 'billing'))
783
+ } else {
784
+ warnings.push('xai: 无法得到已用百分比(weekly 无 creditUsagePercent 且 monthly 无可用 cents)')
785
+ }
786
+
787
+ // on-demand 窗口:cap>0 且有已用 cents 时派生(weekly 优先,字段级回落)
788
+ const onDemandPercent = weekly.onDemandPercent ?? monthly.onDemandPercent
789
+ if (onDemandPercent !== null) {
790
+ windows.push(makeWindow('on-demand', 'On-demand', onDemandPercent, '', null, 'billing'))
791
+ }
792
+
793
+ // 产品行:weekly 有数据用 weekly 的,否则 monthly 的(不做跨端点拼盘)
794
+ const products = weeklyWins && weekly.products.length > 0 ? weekly.products : monthly.products
795
+ windows.push(...products)
796
+
797
+ if (windows.length === 0) throw parseError('xai: billing payload is not quantifiable')
798
+ return { windows, warnings }
799
+ }
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // WorkBuddy(evidence §7)
803
+ // ---------------------------------------------------------------------------
804
+
805
+ /** 错误文案截断到 120 字符,防止上游报错原文刷屏。 */
806
+ function truncateMessage(message) {
807
+ const text = String(message ?? '')
808
+ return text.length > 120 ? text.slice(0, 120) : text
809
+ }
810
+
811
+ /**
812
+ * 解析 WorkBuddy /credits 响应(evidence §7.3–7.4):
813
+ * - accounts[] 每行 → { authIndex, status, message, windows, credits };
814
+ * - nickname/uid 是 PII,**绝不**复制进输出(脱敏在宿主投影层再做,这里先不带入);
815
+ * - totals 是绝对量:total_size>0 时派生「总池」窗口 percent = total_used/total_size×100;
816
+ * - packages[] → {id, label, used, remaining, limit, startsAt, resetsAt}(cycle 起止归一);
817
+ * - fetched_at 可能整体缺失(omitempty + 只在单账号分支设置):有就用原值,
818
+ * 缺失置 '',**绝不拿当前时间冒充采集时刻**;
819
+ * - 零 totals 且无 packages → status 'unknown'(缺数据 ≠ 耗尽,不伪造 0);
820
+ * - error 行 → status 'error',message 截断 120;缺 auth_index → error 行。
821
+ */
822
+ export function parseWorkBuddyCredits(payload) {
823
+ const accountsRaw = Array.isArray(payload?.accounts) ? payload.accounts : null
824
+ if (accountsRaw === null) {
825
+ return {
826
+ status: 'error',
827
+ message: truncateMessage('workbuddy: 响应缺少 accounts 数组'),
828
+ accounts: [],
829
+ }
830
+ }
831
+ const accounts = []
832
+ for (const row of accountsRaw) {
833
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
834
+ accounts.push({ authIndex: null, status: 'error', message: truncateMessage('workbuddy: 账号行格式非法'), windows: [], credits: null })
835
+ continue
836
+ }
837
+ const authIndex = pickField(row, 'auth_index', 'authIndex')
838
+ if (authIndex === undefined || authIndex === null || authIndex === '') {
839
+ accounts.push({ authIndex: null, status: 'error', message: truncateMessage('workbuddy: 账号行缺少 auth_index'), windows: [], credits: null })
840
+ continue
841
+ }
842
+ const errorText = pickField(row, 'error')
843
+ if (typeof errorText === 'string' && errorText.trim() !== '') {
844
+ accounts.push({ authIndex, status: 'error', message: truncateMessage(errorText), windows: [], credits: null })
845
+ continue
846
+ }
847
+ const creditsRaw = pickField(row, 'credits')
848
+ if (creditsRaw === null || typeof creditsRaw !== 'object' || Array.isArray(creditsRaw)) {
849
+ accounts.push({ authIndex, status: 'unknown', message: truncateMessage('workbuddy: 无 credits 数据(不视为耗尽)'), windows: [], credits: null })
850
+ continue
851
+ }
852
+ const totalUsed = toInt(pickField(creditsRaw, 'total_used', 'totalUsed'))
853
+ const totalRemain = toInt(pickField(creditsRaw, 'total_remain', 'totalRemain'))
854
+ const totalSize = toInt(pickField(creditsRaw, 'total_size', 'totalSize'))
855
+ const windows = []
856
+ if (totalSize !== null && totalSize > 0 && totalUsed !== null) {
857
+ windows.push(makeWindow('credits-total', '积分总池', clampPct((totalUsed / totalSize) * 100), '', null, 'account'))
858
+ }
859
+ const packagesRaw = Array.isArray(creditsRaw.packages) ? creditsRaw.packages : []
860
+ const packages = []
861
+ for (let i = 0; i < packagesRaw.length; i++) {
862
+ const pkg = packagesRaw[i]
863
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) continue
864
+ const name = String(pickField(pkg, 'name') ?? `package-${i}`)
865
+ packages.push({
866
+ id: `pkg-${slug(name)}-${i}`,
867
+ label: name,
868
+ used: toInt(pickField(pkg, 'used')),
869
+ remaining: toInt(pickField(pkg, 'remain')),
870
+ limit: toInt(pickField(pkg, 'size')),
871
+ startsAt: normalizeResetAt(pickField(pkg, 'cycle_start', 'cycleStart')),
872
+ resetsAt: normalizeResetAt(pickField(pkg, 'cycle_end', 'cycleEnd')),
873
+ })
874
+ }
875
+ const hasSignal = (totalUsed !== null && totalUsed > 0)
876
+ || (totalSize !== null && totalSize > 0)
877
+ || packages.length > 0
878
+ // fetched_at 缺失置空串,绝不用请求时刻顶替(全量模式不返回该字段,evidence §7.3)
879
+ const fetchedAtRaw = pickField(creditsRaw, 'fetched_at', 'fetchedAt')
880
+ const credits = {
881
+ unit: 'credits',
882
+ used: totalUsed,
883
+ remaining: totalRemain,
884
+ limit: totalSize,
885
+ fetchedAt: typeof fetchedAtRaw === 'string' && fetchedAtRaw.trim() !== '' ? fetchedAtRaw : '',
886
+ packages,
887
+ }
888
+ accounts.push({
889
+ authIndex,
890
+ status: hasSignal ? 'ok' : 'unknown',
891
+ message: hasSignal ? '' : truncateMessage('workbuddy: 无使用信号,额度状态未知(不视为耗尽)'),
892
+ windows,
893
+ credits,
894
+ })
895
+ }
896
+ const anyOk = accounts.some((account) => account.status === 'ok')
897
+ const anyError = accounts.some((account) => account.status === 'error')
898
+ const status = anyOk ? 'ok' : anyError ? 'error' : 'unknown'
899
+ return { status, message: '', accounts }
900
+ }
901
+
902
+ // ---------------------------------------------------------------------------
903
+ // 注册表(adapter 契约:requests(account) 返回固定端点请求列表,parse(payload, {now})
904
+ // 返回 { windows, plan };见本文件头部设计契约)
905
+ // ---------------------------------------------------------------------------
906
+
907
+ /** api-call 通用能力位:全部只读、百分比窗口、带 reset 时刻;无绝对量窗口。 */
908
+ const API_CALL_CAPABILITIES = {
909
+ percentageWindows: true,
910
+ amountWindows: false,
911
+ resetTimes: true,
912
+ readOnly: true,
913
+ }
914
+
915
+ /**
916
+ * 六家 provider 注册表。
917
+ * requests(account) 返回宿主应发出的固定请求表:URL/方法/头全部来自上方常量,
918
+ * 请求头保留**字面量** '$TOKEN$' 占位符,由宿主用凭据替换;禁用户覆盖。
919
+ */
920
+ export const GATEWAY_PROVIDER_ADAPTERS = {
921
+ antigravity: {
922
+ id: 'antigravity',
923
+ transport: 'api-call',
924
+ aliases: ['antigravity'],
925
+ requests(account) {
926
+ // 官方三个配额端点逐个回退(POST,带 project 体);project 缺失或为占位名时请求体省略
927
+ const projectId = antigravityProjectId(account)
928
+ return ANTIGRAVITY_QUOTA_URLS.map((url) => ({
929
+ method: 'POST',
930
+ url,
931
+ headers: ANTIGRAVITY_REQUEST_HEADERS,
932
+ ...(projectId !== undefined && projectId !== null && projectId !== ''
933
+ ? { data: JSON.stringify({ project: projectId }) }
934
+ : {}),
935
+ }))
936
+ },
937
+ parse: parseAntigravityQuota,
938
+ capabilities: API_CALL_CAPABILITIES,
939
+ },
940
+ claude: {
941
+ id: 'claude',
942
+ transport: 'api-call',
943
+ aliases: ['claude', 'anthropic'],
944
+ requests() {
945
+ return [{ method: 'GET', url: CLAUDE_USAGE_URL, headers: CLAUDE_REQUEST_HEADERS }]
946
+ },
947
+ parse: parseClaudeUsage,
948
+ capabilities: API_CALL_CAPABILITIES,
949
+ },
950
+ codex: {
951
+ id: 'codex',
952
+ transport: 'api-call',
953
+ aliases: ['codex'],
954
+ requests(account) {
955
+ // 只读 usage 查询;重置积分的列表与副作用端点均不在额度表请求表内
956
+ return [{ method: 'GET', url: CODEX_USAGE_URL, headers: codexRequestHeaders(account) }]
957
+ },
958
+ parse: parseCodexUsage,
959
+ capabilities: API_CALL_CAPABILITIES,
960
+ },
961
+ kimi: {
962
+ id: 'kimi',
963
+ transport: 'api-call',
964
+ aliases: ['kimi'],
965
+ requests() {
966
+ return [{ method: 'GET', url: KIMI_USAGE_URL, headers: KIMI_REQUEST_HEADERS }]
967
+ },
968
+ parse: parseKimiUsage,
969
+ capabilities: API_CALL_CAPABILITIES,
970
+ },
971
+ xai: {
972
+ id: 'xai',
973
+ transport: 'api-call',
974
+ aliases: ['xai'],
975
+ requests(account) {
976
+ // 同一路径两次并行 GET(?format=credits 差异),取回后交 parseXaiBilling 合并;
977
+ // 付费档不执行任何计费探测请求。
978
+ return xaiBillingRequests(account)
979
+ },
980
+ parse: parseXaiBilling,
981
+ capabilities: API_CALL_CAPABILITIES,
982
+ },
983
+ workbuddy: {
984
+ id: 'workbuddy',
985
+ transport: 'plugin-management',
986
+ aliases: ['workbuddy'],
987
+ path: WORKBUDDY_CREDITS_PATH, // 宿主对 baseURL + path 发只读 GET;插件其余变更路由一律不碰
988
+ parse: parseWorkBuddyCredits,
989
+ capabilities: {
990
+ percentageWindows: true,
991
+ amountWindows: true, // 积分是绝对量,credits 走 amount 渲染,再派生总池窗口
992
+ resetTimes: true,
993
+ readOnly: true,
994
+ },
995
+ },
996
+ }
997
+
998
+ /**
999
+ * provider 归一:去空白、小写;'anthropic' 是 claude 的别名;
1000
+ * 不在注册表里的(如 gemini)返回 null,由外层记入 unsupportedProviders。
1001
+ */
1002
+ export function canonicalGatewayProvider(raw) {
1003
+ const key = String(raw ?? '').trim().toLowerCase()
1004
+ if (key === 'anthropic') return 'claude'
1005
+ return Object.prototype.hasOwnProperty.call(GATEWAY_PROVIDER_ADAPTERS, key) ? key : null
1006
+ }