coding-tool-x 3.6.2 → 3.6.4

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.
Files changed (27) hide show
  1. package/dist/web/assets/{Analytics-DLEiiIbw.js → Analytics-BTmqXkR-.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-BxEw8kS_.js → ConfigTemplates-z3w0kyNl.js} +1 -1
  3. package/dist/web/assets/{Home-Bk6Bsjmm.js → Home-DU-NVre0.js} +1 -1
  4. package/dist/web/assets/{PluginManager-PCYV0eXK.js → PluginManager-44r9LaWV.js} +1 -1
  5. package/dist/web/assets/{ProjectList-AU3SqUA9.js → ProjectList-DW_MEKRy.js} +1 -1
  6. package/dist/web/assets/SessionList-B8upyqsp.js +1 -0
  7. package/dist/web/assets/{SkillManager-DfNofdEQ.js → SkillManager-DnzRfF_o.js} +1 -1
  8. package/dist/web/assets/{WorkspaceManager-DPCSLn59.js → WorkspaceManager-BuRpVJRU.js} +1 -1
  9. package/dist/web/assets/{icons-QFXzEgqM.js → icons-U50_Cfbe.js} +1 -1
  10. package/dist/web/assets/index-BkLQex1R.css +1 -0
  11. package/dist/web/assets/index-gIbIMdSO.js +2 -0
  12. package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-Cjh6GLto.js} +1 -1
  13. package/dist/web/assets/{vendors-DQA4Hw9v.js → vendors--KP7Ux0s.js} +1 -1
  14. package/dist/web/assets/{vue-vendor-B5RqjEjQ.js → vue-vendor-C5aMkRmm.js} +1 -1
  15. package/dist/web/index.html +6 -6
  16. package/package.json +1 -1
  17. package/src/server/api/channel-balances.js +36 -0
  18. package/src/server/api/channels.js +4 -2
  19. package/src/server/api/codex-channels.js +3 -1
  20. package/src/server/api/gemini-channels.js +4 -2
  21. package/src/server/api/opencode-channels.js +4 -2
  22. package/src/server/index.js +1 -0
  23. package/src/server/services/channel-balance.js +1355 -0
  24. package/src/server/services/ui-config.js +4 -0
  25. package/dist/web/assets/SessionList-DIXB0QPe.js +0 -1
  26. package/dist/web/assets/index-C2yKfEbU.js +0 -2
  27. package/dist/web/assets/index-dzKgnRhx.css +0 -1
@@ -0,0 +1,1355 @@
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const crypto = require('crypto');
4
+ const { URL } = require('url');
5
+ const { loadUIConfig } = require('./ui-config');
6
+
7
+ const CACHE_TTL_MS = 5 * 60 * 1000;
8
+ const REQUEST_TIMEOUT_MS = 8000;
9
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
10
+ const QUOTA_UNIT = 500000;
11
+ const VELOERA_QUOTA_UNIT = 1000000;
12
+ const VALID_SOURCES = new Set(['claude', 'codex', 'gemini', 'opencode']);
13
+ const GATEWAY_PATH_SEGMENTS = new Set(['openai', 'anthropic', 'claude', 'gemini', 'codex']);
14
+ const HUB_PLATFORMS = new Set(['new-api', 'one-api', 'one-hub', 'done-hub', 'veloera', 'anyrouter', 'aihubmix']);
15
+ const UNSUPPORTED_BALANCE_PLATFORMS = new Set(['dashscope', 'modelscope']);
16
+
17
+ const balanceCache = new Map();
18
+ const balanceStrategyCache = new Map();
19
+
20
+ function nowIso(now = Date.now()) {
21
+ return new Date(now).toISOString();
22
+ }
23
+
24
+ function stripBearer(value) {
25
+ const token = String(value || '').trim();
26
+ return token.toLowerCase().startsWith('bearer ') ? token.slice(7).trim() : token;
27
+ }
28
+
29
+ function resolveBalanceToken(channel = {}) {
30
+ return stripBearer(
31
+ channel.balanceToken
32
+ || channel.balanceApiKey
33
+ || channel.balanceAuthToken
34
+ || channel.apiKey
35
+ || channel.token
36
+ || channel.authToken
37
+ || ''
38
+ );
39
+ }
40
+
41
+ function buildAuthHeaders(token, extra = {}) {
42
+ return {
43
+ Accept: 'application/json',
44
+ Authorization: `Bearer ${stripBearer(token)}`,
45
+ ...extra
46
+ };
47
+ }
48
+
49
+ function isLikelyModelApiKey(token) {
50
+ return /^sk-[a-z0-9]/i.test(stripBearer(token));
51
+ }
52
+
53
+ function isLikelyOpenRouterApiKey(token) {
54
+ return /^sk-or-/i.test(stripBearer(token));
55
+ }
56
+
57
+ function parseFiniteNumber(value) {
58
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
59
+ if (typeof value === 'string') {
60
+ const parsed = Number(value.trim());
61
+ if (Number.isFinite(parsed)) return parsed;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function roundMoney(value) {
67
+ if (!Number.isFinite(value)) return value;
68
+ return Math.round(value * 1000000) / 1000000;
69
+ }
70
+
71
+ function normalizeQuotaUnit(value, fallback = QUOTA_UNIT) {
72
+ const parsed = parseFiniteNumber(value);
73
+ return parsed != null && parsed > 0 ? parsed : fallback;
74
+ }
75
+
76
+ function formatMoney(value) {
77
+ if (!Number.isFinite(value)) return '';
78
+ return `$${value.toFixed(2)}`;
79
+ }
80
+
81
+ function makeHiddenSnapshot(platform = null, { stale = false, updatedAt = nowIso() } = {}) {
82
+ return {
83
+ visible: false,
84
+ platform,
85
+ updatedAt,
86
+ stale
87
+ };
88
+ }
89
+
90
+ function makeVisibleSnapshot(platform, input, { stale = false, updatedAt = nowIso() } = {}) {
91
+ const remaining = parseFiniteNumber(input.remaining);
92
+ const monthlyRemaining = parseFiniteNumber(input.monthlyRemaining);
93
+ if (remaining == null && monthlyRemaining == null) {
94
+ return makeHiddenSnapshot(platform, { stale, updatedAt });
95
+ }
96
+
97
+ const labelValue = remaining != null ? remaining : monthlyRemaining;
98
+ const labelPrefix = remaining != null ? '余额' : '月余';
99
+ const snapshot = {
100
+ visible: true,
101
+ platform,
102
+ label: `${labelPrefix} ${formatMoney(labelValue)}`,
103
+ updatedAt,
104
+ stale
105
+ };
106
+
107
+ if (remaining != null) snapshot.remaining = roundMoney(remaining);
108
+ const used = parseFiniteNumber(input.used);
109
+ if (used != null) snapshot.used = roundMoney(used);
110
+ const total = parseFiniteNumber(input.total);
111
+ if (total != null) snapshot.total = roundMoney(total);
112
+ if (monthlyRemaining != null) snapshot.monthlyRemaining = roundMoney(monthlyRemaining);
113
+ return snapshot;
114
+ }
115
+
116
+ function normalizeBaseUrl(value) {
117
+ const raw = String(value || '').trim();
118
+ if (!raw) return '';
119
+
120
+ let parsed;
121
+ try {
122
+ parsed = new URL(raw);
123
+ } catch {
124
+ return '';
125
+ }
126
+
127
+ parsed.hash = '';
128
+ parsed.search = '';
129
+ let pathname = parsed.pathname || '/';
130
+ pathname = pathname.replace(/\/+$/, '') || '/';
131
+ const lower = pathname.toLowerCase();
132
+ const suffixes = [
133
+ '/v1/chat/completions',
134
+ '/v1/responses',
135
+ '/v1/messages',
136
+ '/v1beta/models',
137
+ '/chat/completions',
138
+ '/responses',
139
+ '/messages',
140
+ '/v1beta',
141
+ '/v1'
142
+ ];
143
+
144
+ for (const suffix of suffixes) {
145
+ if (lower === suffix || lower.endsWith(suffix)) {
146
+ pathname = pathname.slice(0, pathname.length - suffix.length) || '/';
147
+ break;
148
+ }
149
+ }
150
+
151
+ parsed.pathname = pathname.replace(/\/+$/, '') || '/';
152
+ return parsed.toString().replace(/\/$/, '');
153
+ }
154
+
155
+ function buildBaseCandidates(baseUrl) {
156
+ const normalized = normalizeBaseUrl(baseUrl);
157
+ if (!normalized) return [];
158
+
159
+ const candidates = [normalized];
160
+ try {
161
+ const parsed = new URL(normalized);
162
+ const origin = parsed.origin;
163
+ if (origin && !candidates.includes(origin)) {
164
+ candidates.push(origin);
165
+ }
166
+ } catch {}
167
+ return candidates;
168
+ }
169
+
170
+ function joinUrl(baseUrl, endpoint) {
171
+ const base = String(baseUrl || '').replace(/\/+$/, '');
172
+ const path = String(endpoint || '').startsWith('/') ? endpoint : `/${endpoint}`;
173
+ return `${base}${path}`;
174
+ }
175
+
176
+ function requestJson(url, {
177
+ method = 'GET',
178
+ headers = {},
179
+ body,
180
+ timeoutMs = REQUEST_TIMEOUT_MS,
181
+ redirectCount = 0
182
+ } = {}) {
183
+ return new Promise((resolve, reject) => {
184
+ let parsed;
185
+ try {
186
+ parsed = new URL(url);
187
+ } catch (error) {
188
+ reject(error);
189
+ return;
190
+ }
191
+
192
+ const client = parsed.protocol === 'http:' ? http : https;
193
+ const requestBody = body === undefined
194
+ ? null
195
+ : (typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
196
+ const requestHeaders = { ...headers };
197
+ if (requestBody != null && !Object.keys(requestHeaders).some(key => key.toLowerCase() === 'content-length')) {
198
+ requestHeaders['Content-Length'] = Buffer.byteLength(requestBody);
199
+ }
200
+
201
+ const req = client.request({
202
+ protocol: parsed.protocol,
203
+ hostname: parsed.hostname,
204
+ port: parsed.port,
205
+ path: `${parsed.pathname}${parsed.search}`,
206
+ method,
207
+ headers: requestHeaders
208
+ }, (res) => {
209
+ const chunks = [];
210
+ let total = 0;
211
+
212
+ res.on('data', (chunk) => {
213
+ total += chunk.length;
214
+ if (total > MAX_RESPONSE_BYTES) {
215
+ req.destroy(new Error('response too large'));
216
+ return;
217
+ }
218
+ chunks.push(chunk);
219
+ });
220
+
221
+ res.on('end', () => {
222
+ const raw = Buffer.concat(chunks).toString('utf8');
223
+ let payload = null;
224
+ let parseError = null;
225
+ if (raw) {
226
+ try {
227
+ payload = JSON.parse(raw);
228
+ } catch (error) {
229
+ parseError = error;
230
+ }
231
+ }
232
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirectCount < 3) {
233
+ try {
234
+ const nextUrl = new URL(res.headers.location, parsed).toString();
235
+ resolve(requestJson(nextUrl, {
236
+ method: res.statusCode === 303 ? 'GET' : method,
237
+ headers,
238
+ body: res.statusCode === 303 ? undefined : body,
239
+ timeoutMs,
240
+ redirectCount: redirectCount + 1
241
+ }));
242
+ } catch (error) {
243
+ reject(error);
244
+ }
245
+ return;
246
+ }
247
+ if (res.statusCode < 200 || res.statusCode >= 300) {
248
+ const error = new Error(`HTTP ${res.statusCode}`);
249
+ error.statusCode = res.statusCode;
250
+ error.payload = payload;
251
+ reject(error);
252
+ return;
253
+ }
254
+ if (parseError) {
255
+ reject(parseError);
256
+ return;
257
+ }
258
+ resolve(payload);
259
+ });
260
+ });
261
+
262
+ req.setTimeout(timeoutMs, () => {
263
+ req.destroy(new Error('request timeout'));
264
+ });
265
+ req.on('error', reject);
266
+ if (requestBody != null) {
267
+ req.write(requestBody);
268
+ }
269
+ req.end();
270
+ });
271
+ }
272
+
273
+ async function tryRequestJson(url, options) {
274
+ try {
275
+ return await requestJson(url, options);
276
+ } catch (error) {
277
+ if (error?.payload && typeof error.payload === 'object') {
278
+ return error.payload;
279
+ }
280
+ return null;
281
+ }
282
+ }
283
+
284
+ function detectPlatformByUrlHint(...values) {
285
+ const value = values.map(item => String(item || '')).join(' ').toLowerCase();
286
+ if (value.includes('88code') || value.includes('88-code') || value.includes('rainapp.top')) return '88code';
287
+ if (value.includes('openrouter.ai') || value.includes('openrouter')) return 'openrouter';
288
+ if (value.includes('aihubmix')) return 'aihubmix';
289
+ if (value.includes('dashscope') || value.includes('dashscope.aliyuncs.com') || value.includes('compatible-mode')) return 'dashscope';
290
+ if (value.includes('modelscope') || value.includes('api-inference.modelscope.cn')) return 'modelscope';
291
+ if (value.includes('siliconflow') || value.includes('siliconflow.cn')) return 'siliconflow';
292
+ if (value.includes('sub2api')) return 'sub2api';
293
+ if (value.includes('anyrouter')) return 'anyrouter';
294
+ if (value.includes('donehub') || value.includes('done-hub')) return 'done-hub';
295
+ if (value.includes('onehub') || value.includes('one-hub')) return 'one-hub';
296
+ if (value.includes('veloera')) return 'veloera';
297
+ if (value.includes('oneapi') || value.includes('one-api')) return 'one-api';
298
+ if (value.includes('newapi') || value.includes('new-api')) return 'new-api';
299
+ return null;
300
+ }
301
+
302
+ function resolveStatusPlatform(payload, hintedPlatform = null) {
303
+ if (!payload || typeof payload !== 'object') return null;
304
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
305
+ const systemName = typeof data?.system_name === 'string'
306
+ ? data.system_name.toLowerCase()
307
+ : '';
308
+ const version = typeof data?.version === 'string'
309
+ ? data.version.toLowerCase()
310
+ : '';
311
+ if (payload.success !== true && !systemName && !version) return null;
312
+
313
+ if (hintedPlatform === 'aihubmix' || systemName.includes('aihubmix')) return 'aihubmix';
314
+ if (hintedPlatform === 'anyrouter') return 'anyrouter';
315
+ if (hintedPlatform === 'done-hub') return 'done-hub';
316
+ if (hintedPlatform === 'one-hub') return 'one-hub';
317
+ if (systemName.includes('veloera') || version.includes('veloera')) return 'veloera';
318
+ if (systemName) return hintedPlatform === 'one-api' ? 'one-api' : 'new-api';
319
+ return hintedPlatform || 'one-api';
320
+ }
321
+
322
+ function extractSub2ApiData(payload) {
323
+ if (!payload || typeof payload !== 'object') return null;
324
+ if (typeof payload.code === 'number') {
325
+ if (payload.code !== 0 || payload.data === undefined) return null;
326
+ return payload.data;
327
+ }
328
+ if (payload.data && typeof payload.data === 'object') return payload.data;
329
+ return payload;
330
+ }
331
+
332
+ function isSub2ApiUsagePayload(payload) {
333
+ if (!payload || typeof payload !== 'object') return false;
334
+ const mode = typeof payload.mode === 'string' ? payload.mode.trim().toLowerCase() : '';
335
+ if (!['quota_limited', 'unrestricted'].includes(mode)) return false;
336
+ return Object.prototype.hasOwnProperty.call(payload, 'remaining')
337
+ || Object.prototype.hasOwnProperty.call(payload, 'balance')
338
+ || (payload.quota && typeof payload.quota === 'object')
339
+ || (payload.subscription && typeof payload.subscription === 'object')
340
+ || (payload.usage && typeof payload.usage === 'object');
341
+ }
342
+
343
+ function extractUserSelfData(payload) {
344
+ if (!payload || typeof payload !== 'object') return null;
345
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
346
+ if (!data || typeof data !== 'object') return null;
347
+ if (!Object.prototype.hasOwnProperty.call(data, 'quota')) return null;
348
+ return data;
349
+ }
350
+
351
+ function buildHubBalanceSnapshot(platform, payload) {
352
+ const data = extractUserSelfData(payload);
353
+ if (!data) return makeHiddenSnapshot(platform);
354
+
355
+ const unit = platform === 'veloera'
356
+ ? VELOERA_QUOTA_UNIT
357
+ : normalizeQuotaUnit(data.quota_per_unit || data.quotaPerUnit);
358
+ const quotaRaw = parseFiniteNumber(data.quota);
359
+ const usedRaw = parseFiniteNumber(data.used_quota);
360
+ if (quotaRaw == null) return makeHiddenSnapshot(platform);
361
+
362
+ const quota = quotaRaw / unit;
363
+ const used = (usedRaw || 0) / unit;
364
+ let remaining;
365
+ let total;
366
+
367
+ if (platform === 'new-api' || platform === 'anyrouter' || platform === 'done-hub' || platform === 'aihubmix') {
368
+ remaining = quota;
369
+ total = quota + used;
370
+ } else {
371
+ remaining = quota - used;
372
+ total = quota;
373
+ }
374
+
375
+ return makeVisibleSnapshot(platform, {
376
+ remaining,
377
+ used,
378
+ total
379
+ });
380
+ }
381
+
382
+ function extractNewApiTokenUsageData(payload) {
383
+ if (!payload || typeof payload !== 'object') return null;
384
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
385
+ if (!data || typeof data !== 'object') return null;
386
+ if (!Object.prototype.hasOwnProperty.call(data, 'total_available')) return null;
387
+ return data;
388
+ }
389
+
390
+ function buildNewApiTokenUsageSnapshot(payload, { quotaUnit = QUOTA_UNIT } = {}) {
391
+ const data = extractNewApiTokenUsageData(payload);
392
+ if (!data) return makeHiddenSnapshot('new-api');
393
+
394
+ const availableRaw = parseFiniteNumber(data.total_available);
395
+ if (availableRaw == null) return makeHiddenSnapshot('new-api');
396
+ const totalRaw = parseFiniteNumber(data.total_granted);
397
+ const usedRaw = parseFiniteNumber(data.total_used);
398
+ const unit = normalizeQuotaUnit(data.quota_per_unit || data.quotaPerUnit || quotaUnit);
399
+
400
+ const remaining = Math.max(0, availableRaw / unit);
401
+ return makeVisibleSnapshot('new-api', {
402
+ remaining,
403
+ used: usedRaw != null ? usedRaw / unit : undefined,
404
+ total: totalRaw != null ? totalRaw / unit : undefined
405
+ });
406
+ }
407
+
408
+ function extractOpenRouterData(payload) {
409
+ if (!payload || typeof payload !== 'object') return null;
410
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
411
+ return data && typeof data === 'object' ? data : null;
412
+ }
413
+
414
+ function buildOpenRouterKeySnapshot(payload) {
415
+ const data = extractOpenRouterData(payload);
416
+ if (!data) return makeHiddenSnapshot('openrouter');
417
+
418
+ const limitRemaining = parseFiniteNumber(data.limit_remaining ?? data.limitRemaining);
419
+ const limit = parseFiniteNumber(data.limit);
420
+ const usage = parseFiniteNumber(data.usage);
421
+ if (limitRemaining != null) {
422
+ return makeVisibleSnapshot('openrouter', {
423
+ remaining: Math.max(0, limitRemaining),
424
+ used: usage,
425
+ total: limit
426
+ });
427
+ }
428
+
429
+ if (limit != null && usage != null) {
430
+ return makeVisibleSnapshot('openrouter', {
431
+ remaining: Math.max(0, limit - usage),
432
+ used: usage,
433
+ total: limit
434
+ });
435
+ }
436
+
437
+ return makeHiddenSnapshot('openrouter');
438
+ }
439
+
440
+ function buildOpenRouterCreditsSnapshot(payload) {
441
+ const data = extractOpenRouterData(payload);
442
+ if (!data) return makeHiddenSnapshot('openrouter');
443
+
444
+ const totalCredits = parseFiniteNumber(data.total_credits ?? data.totalCredits);
445
+ const totalUsage = parseFiniteNumber(data.total_usage ?? data.totalUsage);
446
+ if (totalCredits == null || totalUsage == null) {
447
+ return makeHiddenSnapshot('openrouter');
448
+ }
449
+
450
+ return makeVisibleSnapshot('openrouter', {
451
+ remaining: Math.max(0, totalCredits - totalUsage),
452
+ used: totalUsage,
453
+ total: totalCredits
454
+ });
455
+ }
456
+
457
+ function buildSiliconFlowUserInfoSnapshot(payload) {
458
+ const data = payload?.data && typeof payload.data === 'object' ? payload.data : payload;
459
+ if (!data || typeof data !== 'object') return makeHiddenSnapshot('siliconflow');
460
+
461
+ const remaining = parseFiniteNumber(data.balance);
462
+ if (remaining == null) return makeHiddenSnapshot('siliconflow');
463
+ const chargeBalance = parseFiniteNumber(data.chargeBalance);
464
+ const totalBalance = parseFiniteNumber(data.totalBalance);
465
+ return makeVisibleSnapshot('siliconflow', {
466
+ remaining,
467
+ used: totalBalance != null ? Math.max(0, totalBalance - remaining) : undefined,
468
+ total: totalBalance ?? chargeBalance ?? remaining
469
+ });
470
+ }
471
+
472
+ function extractQuotaUnitFromStatus(payload) {
473
+ if (!payload || typeof payload !== 'object') return null;
474
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
475
+ return normalizeQuotaUnit(data?.quota_per_unit || data?.quotaPerUnit, null);
476
+ }
477
+
478
+ function parseSubscriptionItems(payload) {
479
+ const data = extractSub2ApiData(payload);
480
+ const candidates = [
481
+ data?.subscriptions,
482
+ data?.items,
483
+ data?.data,
484
+ payload?.subscriptions,
485
+ payload?.items,
486
+ Array.isArray(data) ? data : null
487
+ ];
488
+ for (const candidate of candidates) {
489
+ if (Array.isArray(candidate)) return candidate;
490
+ }
491
+ return [];
492
+ }
493
+
494
+ function readMoneyField(item, ...keys) {
495
+ for (const key of keys) {
496
+ const value = parseFiniteNumber(item?.[key]);
497
+ if (value != null) return value;
498
+ }
499
+ return null;
500
+ }
501
+
502
+ async function fetchSub2ApiSubscriptionSummary(baseUrl, headers) {
503
+ const endpoints = ['/api/v1/subscriptions/summary', '/api/v1/subscriptions/active'];
504
+ for (const endpoint of endpoints) {
505
+ const payload = await tryRequestJson(joinUrl(baseUrl, endpoint), { headers });
506
+ if (!payload) continue;
507
+ const items = parseSubscriptionItems(payload);
508
+ if (!items.length) continue;
509
+
510
+ let monthlyRemaining = 0;
511
+ let hasMonthlyLimit = false;
512
+ for (const item of items) {
513
+ const used = readMoneyField(item, 'monthlyUsedUsd', 'monthly_used_usd', 'monthly_used', 'used_monthly_usd') || 0;
514
+ const limit = readMoneyField(item, 'monthlyLimitUsd', 'monthly_limit_usd', 'monthly_limit', 'limit_monthly_usd');
515
+ if (limit == null) continue;
516
+ hasMonthlyLimit = true;
517
+ monthlyRemaining += Math.max(0, limit - used);
518
+ }
519
+
520
+ if (hasMonthlyLimit) {
521
+ return { monthlyRemaining };
522
+ }
523
+ }
524
+ return {};
525
+ }
526
+
527
+ function readSub2ApiMonthlyRemaining(subscription) {
528
+ if (!subscription || typeof subscription !== 'object') return null;
529
+ const used = readMoneyField(subscription, 'monthlyUsedUsd', 'monthly_usage_usd', 'monthly_used_usd', 'monthly_used', 'used_monthly_usd') || 0;
530
+ const limit = readMoneyField(subscription, 'monthlyLimitUsd', 'monthly_limit_usd', 'monthly_limit', 'limit_monthly_usd');
531
+ if (limit == null) return null;
532
+ return Math.max(0, limit - used);
533
+ }
534
+
535
+ function nonNegativeMoney(value) {
536
+ const parsed = parseFiniteNumber(value);
537
+ return parsed != null && parsed >= 0 ? parsed : null;
538
+ }
539
+
540
+ function buildSub2ApiUsageSnapshot(payload) {
541
+ if (!isSub2ApiUsagePayload(payload)) return makeHiddenSnapshot('sub2api');
542
+ if (payload.isValid === false) return makeHiddenSnapshot('sub2api');
543
+
544
+ const quota = payload.quota && typeof payload.quota === 'object' ? payload.quota : null;
545
+ if (quota) {
546
+ const remaining = nonNegativeMoney(quota.remaining) ?? nonNegativeMoney(payload.remaining);
547
+ if (remaining != null) {
548
+ return makeVisibleSnapshot('sub2api', {
549
+ remaining,
550
+ used: readMoneyField(quota, 'used', 'quota_used', 'used_usd'),
551
+ total: readMoneyField(quota, 'limit', 'quota', 'total')
552
+ });
553
+ }
554
+ }
555
+
556
+ const remaining = nonNegativeMoney(payload.balance) ?? nonNegativeMoney(payload.remaining);
557
+ const monthlyRemaining = readSub2ApiMonthlyRemaining(payload.subscription);
558
+ return makeVisibleSnapshot('sub2api', {
559
+ remaining,
560
+ monthlyRemaining
561
+ });
562
+ }
563
+
564
+ function addUnique(list, value) {
565
+ if (value && !list.includes(value)) {
566
+ list.push(value);
567
+ }
568
+ }
569
+
570
+ function toOriginApiBase(value) {
571
+ try {
572
+ const parsed = new URL(value);
573
+ return `${parsed.origin}/api`;
574
+ } catch {
575
+ return '';
576
+ }
577
+ }
578
+
579
+ function toPathApiBase(value) {
580
+ try {
581
+ const normalized = normalizeBaseUrl(value);
582
+ if (!normalized) return '';
583
+ return normalized.toLowerCase().endsWith('/api') ? normalized : joinUrl(normalized, '/api');
584
+ } catch {
585
+ return '';
586
+ }
587
+ }
588
+
589
+ function toOpenRouterApiBase(value) {
590
+ try {
591
+ const parsed = new URL(String(value || '').trim());
592
+ parsed.hash = '';
593
+ parsed.search = '';
594
+ const origin = parsed.origin;
595
+ const host = parsed.hostname.toLowerCase();
596
+ const pathname = (parsed.pathname || '').replace(/\/+$/, '');
597
+ const lower = pathname.toLowerCase();
598
+
599
+ if (!pathname || pathname === '/') {
600
+ return `${origin}/api/v1`;
601
+ }
602
+ if (lower.endsWith('/api/v1')) {
603
+ return `${origin}${pathname}`;
604
+ }
605
+ if (lower.endsWith('/api')) {
606
+ return `${origin}${pathname}/v1`;
607
+ }
608
+ if (lower.endsWith('/v1')) {
609
+ const parent = pathname.slice(0, -3).replace(/\/+$/, '');
610
+ if (!parent || parent === '') return `${origin}/api/v1`;
611
+ if (parent.toLowerCase().endsWith('/api')) return `${origin}${parent}/v1`;
612
+ return `${origin}${parent}/api/v1`;
613
+ }
614
+ if (host === 'openrouter.ai' || host.endsWith('.openrouter.ai')) {
615
+ return `${origin}/api/v1`;
616
+ }
617
+ return `${origin}${pathname}/api/v1`;
618
+ } catch {
619
+ return '';
620
+ }
621
+ }
622
+
623
+ function buildOpenRouterApiBaseCandidates(baseUrl, channel = {}) {
624
+ const candidates = [];
625
+ const inputs = [
626
+ channel.balanceBaseUrl,
627
+ channel.websiteUrl,
628
+ channel.baseUrl,
629
+ baseUrl
630
+ ].filter(Boolean);
631
+
632
+ for (const input of inputs) {
633
+ addUnique(candidates, toOpenRouterApiBase(input));
634
+ }
635
+ return candidates;
636
+ }
637
+
638
+ function buildSiliconFlowApiBaseCandidates(baseUrl, channel = {}) {
639
+ const candidates = [];
640
+ const inputs = [
641
+ channel.balanceBaseUrl,
642
+ channel.websiteUrl,
643
+ channel.baseUrl,
644
+ baseUrl
645
+ ].filter(Boolean);
646
+
647
+ for (const input of inputs) {
648
+ try {
649
+ const parsed = new URL(input);
650
+ const origin = parsed.origin;
651
+ const pathname = (parsed.pathname || '').replace(/\/+$/, '');
652
+ const lower = pathname.toLowerCase();
653
+ if (lower.endsWith('/v1')) {
654
+ addUnique(candidates, `${origin}${pathname}`);
655
+ } else {
656
+ addUnique(candidates, `${origin}/v1`);
657
+ }
658
+ } catch {}
659
+ }
660
+ return candidates;
661
+ }
662
+
663
+ function hasGatewayPathSegment(value) {
664
+ try {
665
+ const parsed = new URL(normalizeBaseUrl(value));
666
+ return parsed.pathname
667
+ .split('/')
668
+ .filter(Boolean)
669
+ .some(segment => GATEWAY_PATH_SEGMENTS.has(segment.toLowerCase()));
670
+ } catch {
671
+ return false;
672
+ }
673
+ }
674
+
675
+ function build88CodeApiBaseCandidates(baseUrl, channel = {}) {
676
+ const candidates = [];
677
+ const inputs = [
678
+ channel.balanceBaseUrl,
679
+ channel.websiteUrl,
680
+ channel.baseUrl,
681
+ baseUrl
682
+ ].filter(Boolean);
683
+
684
+ for (const input of inputs) {
685
+ const originApiBase = toOriginApiBase(input);
686
+ const pathApiBase = toPathApiBase(input);
687
+ if (hasGatewayPathSegment(input)) {
688
+ addUnique(candidates, originApiBase);
689
+ addUnique(candidates, pathApiBase);
690
+ } else {
691
+ addUnique(candidates, pathApiBase);
692
+ addUnique(candidates, originApiBase);
693
+ }
694
+ }
695
+ return candidates;
696
+ }
697
+
698
+ function get88CodeApiBaseFromStrategy(strategy = {}) {
699
+ return strategy.apiBase || strategy.baseUrl;
700
+ }
701
+
702
+ function get88CodeRequestBody(channel = {}) {
703
+ const model = String(
704
+ channel.model
705
+ || channel.speedTestModel
706
+ || channel.defaultModel
707
+ || ''
708
+ ).trim();
709
+ return model ? { model } : {};
710
+ }
711
+
712
+ function extract88CodeData(payload) {
713
+ if (!payload || typeof payload !== 'object') return null;
714
+ if (!Object.prototype.hasOwnProperty.call(payload, 'data')) return null;
715
+ return payload.data;
716
+ }
717
+
718
+ function normalize88CodeSubscription(subscription = {}) {
719
+ const plan = subscription.subscriptionPlan && typeof subscription.subscriptionPlan === 'object'
720
+ ? subscription.subscriptionPlan
721
+ : {};
722
+ const creditLimit = parseFiniteNumber(subscription.creditLimit)
723
+ ?? parseFiniteNumber(plan.creditLimit)
724
+ ?? 0;
725
+ return {
726
+ id: parseFiniteNumber(subscription.id) ?? 0,
727
+ planName: String(subscription.subscriptionPlanName || subscription.subscriptionName || plan.subscriptionName || ''),
728
+ status: String(subscription.subscriptionStatus || subscription.status || ''),
729
+ isActive: subscription.isActive === true,
730
+ remainingDays: parseFiniteNumber(subscription.remainingDays) ?? 0,
731
+ creditLimit,
732
+ currentCredits: parseFiniteNumber(subscription.currentCredits) ?? 0
733
+ };
734
+ }
735
+
736
+ function get88CodeBillingPriority(planName) {
737
+ const normalized = String(planName || '').trim().toUpperCase();
738
+ if (normalized === 'FREE') return 3;
739
+ if (normalized === 'PAYGO') return 2;
740
+ return 1;
741
+ }
742
+
743
+ function select88CodeSubscription(subscriptions = []) {
744
+ const active = subscriptions
745
+ .map(normalize88CodeSubscription)
746
+ .filter(subscription => subscription.isActive)
747
+ .filter(subscription => !subscription.status || subscription.status === '活跃中' || subscription.remainingDays > 0)
748
+ .sort((left, right) => {
749
+ const priorityDiff = get88CodeBillingPriority(left.planName) - get88CodeBillingPriority(right.planName);
750
+ if (priorityDiff !== 0) return priorityDiff;
751
+ return left.id - right.id;
752
+ });
753
+
754
+ return active.find(subscription => (
755
+ subscription.planName.toUpperCase() !== 'FREE'
756
+ && subscription.creditLimit > 0
757
+ && subscription.currentCredits < subscription.creditLimit
758
+ ))
759
+ || active.find(subscription => (
760
+ subscription.planName.toUpperCase() !== 'FREE'
761
+ && subscription.currentCredits > 0
762
+ ))
763
+ || null;
764
+ }
765
+
766
+ function hasOnly88CodeFreeSubscription(entities = []) {
767
+ const active = Array.isArray(entities)
768
+ ? entities.filter(item => item?.isActive === true)
769
+ : [];
770
+ if (!active.length) return false;
771
+ return !active.some((item) => {
772
+ const name = String(item.subscriptionName || item.subscriptionPlanName || '').toUpperCase();
773
+ return name && name !== 'FREE' && name !== 'PAYGO';
774
+ });
775
+ }
776
+
777
+ function build88CodeBalanceSnapshotFromValues(values = {}) {
778
+ const remainingRaw = parseFiniteNumber(values.currentCredits);
779
+ const totalRaw = parseFiniteNumber(values.creditLimit);
780
+ if (remainingRaw == null && totalRaw == null) {
781
+ return makeHiddenSnapshot('88code');
782
+ }
783
+
784
+ const remaining = Math.max(0, remainingRaw ?? 0);
785
+ const total = totalRaw != null && totalRaw > 0 ? totalRaw : undefined;
786
+ const used = total != null ? Math.max(0, total - remaining) : undefined;
787
+ return makeVisibleSnapshot('88code', {
788
+ remaining,
789
+ used,
790
+ total
791
+ });
792
+ }
793
+
794
+ function build88CodeUsageSnapshot(data) {
795
+ if (!data || typeof data !== 'object') return makeHiddenSnapshot('88code');
796
+ const entities = Array.isArray(data.subscriptionEntityList) ? data.subscriptionEntityList : [];
797
+ const activeEntity = entities
798
+ .filter(item => item?.isActive === true)
799
+ .filter(item => String(item.subscriptionName || '').toUpperCase() !== 'FREE')
800
+ .find((item) => {
801
+ const total = parseFiniteNumber(item.creditLimit);
802
+ const remaining = parseFiniteNumber(item.currentCredits);
803
+ return total != null && remaining != null && remaining < total;
804
+ });
805
+
806
+ const selected = activeEntity || data;
807
+ return build88CodeBalanceSnapshotFromValues({
808
+ creditLimit: selected.creditLimit,
809
+ currentCredits: selected.currentCredits
810
+ });
811
+ }
812
+
813
+ function is88CodeUsageDataValid(data) {
814
+ if (!data || typeof data !== 'object') return false;
815
+ const creditLimit = parseFiniteNumber(data.creditLimit) ?? 0;
816
+ const entities = Array.isArray(data.subscriptionEntityList) ? data.subscriptionEntityList : [];
817
+ return creditLimit > 0 || entities.length > 0;
818
+ }
819
+
820
+ function build88CodeSubscriptionSnapshot(payload) {
821
+ const data = extract88CodeData(payload);
822
+ const subscriptions = Array.isArray(data) ? data : [];
823
+ const selected = select88CodeSubscription(subscriptions);
824
+ if (!selected) return makeHiddenSnapshot('88code');
825
+ return build88CodeBalanceSnapshotFromValues({
826
+ creditLimit: selected.creditLimit,
827
+ currentCredits: selected.currentCredits
828
+ });
829
+ }
830
+
831
+ async function post88CodeJson(url, token, channel) {
832
+ return requestJson(url, {
833
+ method: 'POST',
834
+ headers: buildAuthHeaders(token, { 'Content-Type': 'application/json' }),
835
+ body: get88CodeRequestBody(channel)
836
+ });
837
+ }
838
+
839
+ async function run88CodeUsageStrategy(strategy, token, channel = {}) {
840
+ const apiBase = get88CodeApiBaseFromStrategy(strategy);
841
+ const payload = await post88CodeJson(joinUrl(apiBase, '/usage'), token, channel);
842
+ const usageData = extract88CodeData(payload);
843
+ if (!is88CodeUsageDataValid(usageData)) return makeHiddenSnapshot('88code');
844
+ const snapshot = build88CodeUsageSnapshot(usageData);
845
+ if (
846
+ snapshot.visible
847
+ && !hasOnly88CodeFreeSubscription(usageData.subscriptionEntityList)
848
+ && Number(snapshot.remaining || 0) > 0
849
+ ) {
850
+ return snapshot;
851
+ }
852
+ return makeHiddenSnapshot('88code');
853
+ }
854
+
855
+ async function run88CodeSubscriptionStrategy(strategy, token, channel = {}) {
856
+ const apiBase = get88CodeApiBaseFromStrategy(strategy);
857
+ const payload = await post88CodeJson(joinUrl(apiBase, '/subscription'), token, channel);
858
+ return build88CodeSubscriptionSnapshot(payload);
859
+ }
860
+
861
+ function buildBalanceProbeStrategies(baseUrl, channel = {}, token = '') {
862
+ const hintedPlatform = detectPlatformByUrlHint(
863
+ channel.providerKey,
864
+ channel.name,
865
+ channel.websiteUrl,
866
+ channel.baseUrl || baseUrl
867
+ );
868
+ const strategies = [];
869
+ const seen = new Set();
870
+ const add = (strategy) => {
871
+ const key = JSON.stringify(strategy);
872
+ if (!seen.has(key)) {
873
+ seen.add(key);
874
+ strategies.push(strategy);
875
+ }
876
+ };
877
+ const addSub2ApiStrategies = () => {
878
+ if (isLikelyModelApiKey(token)) {
879
+ add({ type: 'sub2api-usage', baseUrl });
880
+ } else {
881
+ add({ type: 'sub2api-auth', baseUrl });
882
+ add({ type: 'sub2api-usage', baseUrl });
883
+ }
884
+ };
885
+ const addHubStrategy = () => {
886
+ add({
887
+ type: 'hub-user-self',
888
+ baseUrl,
889
+ platform: HUB_PLATFORMS.has(hintedPlatform) ? hintedPlatform : null,
890
+ hintedPlatform
891
+ });
892
+ };
893
+ const addNewApiTokenUsageStrategy = () => {
894
+ add({ type: 'new-api-token-usage', baseUrl });
895
+ };
896
+ const addNewApiTokenUsageWithStatusStrategy = () => {
897
+ add({ type: 'new-api-token-usage-status', baseUrl });
898
+ };
899
+ const add88CodeStrategies = () => {
900
+ for (const apiBase of build88CodeApiBaseCandidates(baseUrl, channel)) {
901
+ add({ type: '88code-usage', baseUrl, apiBase });
902
+ add({ type: '88code-subscription', baseUrl, apiBase });
903
+ }
904
+ };
905
+ const addOpenRouterStrategies = () => {
906
+ for (const apiBase of buildOpenRouterApiBaseCandidates(baseUrl, channel)) {
907
+ add({ type: 'openrouter-key', baseUrl, apiBase, endpoint: '/key' });
908
+ add({ type: 'openrouter-key', baseUrl, apiBase, endpoint: '/auth/key' });
909
+ add({ type: 'openrouter-credits', baseUrl, apiBase });
910
+ }
911
+ };
912
+ const addSiliconFlowStrategies = () => {
913
+ for (const apiBase of buildSiliconFlowApiBaseCandidates(baseUrl, channel)) {
914
+ add({ type: 'siliconflow-user-info', baseUrl, apiBase });
915
+ }
916
+ };
917
+
918
+ if (UNSUPPORTED_BALANCE_PLATFORMS.has(hintedPlatform)) {
919
+ add({ type: 'unsupported-balance', platform: hintedPlatform, baseUrl });
920
+ return strategies;
921
+ }
922
+
923
+ if (hintedPlatform === 'siliconflow') {
924
+ addSiliconFlowStrategies();
925
+ return strategies;
926
+ }
927
+
928
+ if (hintedPlatform === '88code') {
929
+ add88CodeStrategies();
930
+ addSub2ApiStrategies();
931
+ addHubStrategy();
932
+ return strategies;
933
+ }
934
+
935
+ if (hintedPlatform === 'openrouter' || isLikelyOpenRouterApiKey(token)) {
936
+ addOpenRouterStrategies();
937
+ addSub2ApiStrategies();
938
+ addNewApiTokenUsageWithStatusStrategy();
939
+ addHubStrategy();
940
+ return strategies;
941
+ }
942
+
943
+ if (hintedPlatform === 'sub2api') {
944
+ addSub2ApiStrategies();
945
+ addNewApiTokenUsageWithStatusStrategy();
946
+ addHubStrategy();
947
+ return strategies;
948
+ }
949
+
950
+ if (HUB_PLATFORMS.has(hintedPlatform)) {
951
+ if (hintedPlatform === 'new-api') {
952
+ addNewApiTokenUsageWithStatusStrategy();
953
+ }
954
+ addHubStrategy();
955
+ addSub2ApiStrategies();
956
+ return strategies;
957
+ }
958
+
959
+ if (isLikelyModelApiKey(token)) {
960
+ addSub2ApiStrategies();
961
+ addNewApiTokenUsageWithStatusStrategy();
962
+ addHubStrategy();
963
+ } else {
964
+ addHubStrategy();
965
+ addNewApiTokenUsageWithStatusStrategy();
966
+ addSub2ApiStrategies();
967
+ }
968
+ return strategies;
969
+ }
970
+
971
+ async function runSub2ApiAuthStrategy(strategy, token) {
972
+ const headers = buildAuthHeaders(token);
973
+ const payload = await tryRequestJson(joinUrl(strategy.baseUrl, '/api/v1/auth/me'), { headers });
974
+ const data = extractSub2ApiData(payload);
975
+ const balance = parseFiniteNumber(data?.balance);
976
+ if (balance == null) return makeHiddenSnapshot('sub2api');
977
+
978
+ const summary = await fetchSub2ApiSubscriptionSummary(strategy.baseUrl, headers);
979
+ return makeVisibleSnapshot('sub2api', {
980
+ remaining: balance,
981
+ used: 0,
982
+ total: balance,
983
+ monthlyRemaining: summary.monthlyRemaining
984
+ });
985
+ }
986
+
987
+ async function runSub2ApiUsageStrategy(strategy, token) {
988
+ const payload = await tryRequestJson(joinUrl(strategy.baseUrl, '/v1/usage'), {
989
+ headers: buildAuthHeaders(token)
990
+ });
991
+ return buildSub2ApiUsageSnapshot(payload);
992
+ }
993
+
994
+ async function runHubUserSelfStrategy(strategy, token) {
995
+ const headers = buildAuthHeaders(token);
996
+ let platform = strategy.platform;
997
+ let quotaUnit = null;
998
+ if (!HUB_PLATFORMS.has(platform)) {
999
+ const status = await tryRequestJson(joinUrl(strategy.baseUrl, '/api/status'), { headers: { Accept: 'application/json' } });
1000
+ platform = resolveStatusPlatform(status, strategy.hintedPlatform);
1001
+ quotaUnit = extractQuotaUnitFromStatus(status);
1002
+ }
1003
+ if (!HUB_PLATFORMS.has(platform)) return { snapshot: makeHiddenSnapshot(platform), strategy };
1004
+
1005
+ const payload = await requestJson(joinUrl(strategy.baseUrl, '/api/user/self'), { headers });
1006
+ const snapshot = buildHubBalanceSnapshot(platform, {
1007
+ ...payload,
1008
+ data: payload?.data && typeof payload.data === 'object'
1009
+ ? { ...payload.data, quota_per_unit: quotaUnit || payload.data.quota_per_unit }
1010
+ : payload?.data
1011
+ });
1012
+ return {
1013
+ snapshot,
1014
+ strategy: {
1015
+ ...strategy,
1016
+ platform,
1017
+ quotaUnit: quotaUnit || strategy.quotaUnit
1018
+ }
1019
+ };
1020
+ }
1021
+
1022
+ async function runNewApiTokenUsageStrategy(strategy, token) {
1023
+ const payload = await requestJson(joinUrl(strategy.baseUrl, '/api/usage/token'), {
1024
+ headers: buildAuthHeaders(token)
1025
+ });
1026
+ return buildNewApiTokenUsageSnapshot(payload, { quotaUnit: strategy.quotaUnit || QUOTA_UNIT });
1027
+ }
1028
+
1029
+ async function runNewApiTokenUsageWithStatusStrategy(strategy, token) {
1030
+ const status = await tryRequestJson(joinUrl(strategy.baseUrl, '/api/status'), {
1031
+ headers: { Accept: 'application/json' }
1032
+ });
1033
+ const quotaUnit = extractQuotaUnitFromStatus(status) || QUOTA_UNIT;
1034
+ const snapshot = await runNewApiTokenUsageStrategy({ ...strategy, quotaUnit }, token);
1035
+ return {
1036
+ snapshot,
1037
+ strategy: {
1038
+ ...strategy,
1039
+ quotaUnit
1040
+ }
1041
+ };
1042
+ }
1043
+
1044
+ async function runOpenRouterKeyStrategy(strategy, token) {
1045
+ const payload = await requestJson(joinUrl(strategy.apiBase || strategy.baseUrl, strategy.endpoint || '/key'), {
1046
+ headers: buildAuthHeaders(token)
1047
+ });
1048
+ return buildOpenRouterKeySnapshot(payload);
1049
+ }
1050
+
1051
+ async function runOpenRouterCreditsStrategy(strategy, token) {
1052
+ const payload = await requestJson(joinUrl(strategy.apiBase || strategy.baseUrl, '/credits'), {
1053
+ headers: buildAuthHeaders(token)
1054
+ });
1055
+ return buildOpenRouterCreditsSnapshot(payload);
1056
+ }
1057
+
1058
+ async function runSiliconFlowUserInfoStrategy(strategy, token) {
1059
+ const payload = await requestJson(joinUrl(strategy.apiBase || strategy.baseUrl, '/user/info'), {
1060
+ headers: buildAuthHeaders(token)
1061
+ });
1062
+ return buildSiliconFlowUserInfoSnapshot(payload);
1063
+ }
1064
+
1065
+ async function runBalanceStrategy(strategy, token, channel = {}) {
1066
+ if (!strategy || !strategy.type) {
1067
+ return { snapshot: makeHiddenSnapshot(), strategy };
1068
+ }
1069
+
1070
+ if (strategy.type === '88code-usage') {
1071
+ return {
1072
+ snapshot: await run88CodeUsageStrategy(strategy, token, channel),
1073
+ strategy
1074
+ };
1075
+ }
1076
+ if (strategy.type === '88code-subscription') {
1077
+ return {
1078
+ snapshot: await run88CodeSubscriptionStrategy(strategy, token, channel),
1079
+ strategy
1080
+ };
1081
+ }
1082
+ if (strategy.type === 'sub2api-auth') {
1083
+ return {
1084
+ snapshot: await runSub2ApiAuthStrategy(strategy, token),
1085
+ strategy
1086
+ };
1087
+ }
1088
+ if (strategy.type === 'sub2api-usage') {
1089
+ return {
1090
+ snapshot: await runSub2ApiUsageStrategy(strategy, token),
1091
+ strategy
1092
+ };
1093
+ }
1094
+ if (strategy.type === 'hub-user-self') {
1095
+ return runHubUserSelfStrategy(strategy, token);
1096
+ }
1097
+ if (strategy.type === 'new-api-token-usage') {
1098
+ return {
1099
+ snapshot: await runNewApiTokenUsageStrategy(strategy, token),
1100
+ strategy
1101
+ };
1102
+ }
1103
+ if (strategy.type === 'new-api-token-usage-status') {
1104
+ return runNewApiTokenUsageWithStatusStrategy(strategy, token);
1105
+ }
1106
+ if (strategy.type === 'openrouter-key') {
1107
+ return {
1108
+ snapshot: await runOpenRouterKeyStrategy(strategy, token),
1109
+ strategy
1110
+ };
1111
+ }
1112
+ if (strategy.type === 'openrouter-credits') {
1113
+ return {
1114
+ snapshot: await runOpenRouterCreditsStrategy(strategy, token),
1115
+ strategy
1116
+ };
1117
+ }
1118
+ if (strategy.type === 'unsupported-balance') {
1119
+ return {
1120
+ snapshot: makeHiddenSnapshot(strategy.platform),
1121
+ strategy
1122
+ };
1123
+ }
1124
+ if (strategy.type === 'siliconflow-user-info') {
1125
+ return {
1126
+ snapshot: await runSiliconFlowUserInfoStrategy(strategy, token),
1127
+ strategy
1128
+ };
1129
+ }
1130
+
1131
+ return { snapshot: makeHiddenSnapshot(), strategy };
1132
+ }
1133
+
1134
+ async function tryBalanceStrategy(strategy, token, channel = {}) {
1135
+ try {
1136
+ const result = await runBalanceStrategy(strategy, token, channel);
1137
+ if (result?.snapshot?.visible) return result;
1138
+ } catch {
1139
+ // Probe errors are intentionally silent; unsupported balances stay hidden.
1140
+ }
1141
+ return null;
1142
+ }
1143
+
1144
+ async function probeBalanceFromBase(baseUrl, channel, token) {
1145
+ let lastSnapshot = null;
1146
+ for (const strategy of buildBalanceProbeStrategies(baseUrl, channel, token)) {
1147
+ const result = await tryBalanceStrategy(strategy, token, channel);
1148
+ if (result) return result;
1149
+ if (!lastSnapshot) {
1150
+ lastSnapshot = makeHiddenSnapshot(
1151
+ strategy.type?.startsWith('88code') ? '88code'
1152
+ : strategy.type?.startsWith('openrouter') ? 'openrouter'
1153
+ : strategy.type?.startsWith('siliconflow') ? 'siliconflow'
1154
+ : strategy.type?.startsWith('sub2api') ? 'sub2api'
1155
+ : strategy.platform || null
1156
+ );
1157
+ }
1158
+ }
1159
+ return lastSnapshot ? { snapshot: lastSnapshot, strategy: null } : null;
1160
+ }
1161
+
1162
+ function buildCacheKey(source, channel) {
1163
+ const token = resolveBalanceToken(channel);
1164
+ const tokenHash = token
1165
+ ? crypto.createHash('sha256').update(token).digest('hex').slice(0, 16)
1166
+ : 'no-token';
1167
+ return [
1168
+ source,
1169
+ channel.id || channel.name || 'unknown',
1170
+ normalizeBaseUrl(channel.baseUrl || ''),
1171
+ tokenHash
1172
+ ].join(':');
1173
+ }
1174
+
1175
+ async function refreshChannelBalanceSnapshot(source, channel, options = {}) {
1176
+ const force = options.force === true;
1177
+ const currentTime = options.now || Date.now();
1178
+ const key = buildCacheKey(source, channel);
1179
+ const cached = balanceCache.get(key);
1180
+
1181
+ if (!force && cached && cached.expiresAt > currentTime) {
1182
+ return cached.snapshot;
1183
+ }
1184
+
1185
+ const bases = buildBaseCandidates(channel.baseUrl);
1186
+ if (!bases.length || !resolveBalanceToken(channel)) {
1187
+ return makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
1188
+ }
1189
+
1190
+ let lastSnapshot = null;
1191
+ const token = resolveBalanceToken(channel);
1192
+ const cachedStrategy = balanceStrategyCache.get(key);
1193
+ if (cachedStrategy) {
1194
+ const strategyResult = await tryBalanceStrategy(cachedStrategy, token, channel);
1195
+ if (strategyResult?.snapshot?.visible) {
1196
+ balanceStrategyCache.set(key, strategyResult.strategy);
1197
+ balanceCache.set(key, {
1198
+ snapshot: strategyResult.snapshot,
1199
+ expiresAt: currentTime + CACHE_TTL_MS
1200
+ });
1201
+ return strategyResult.snapshot;
1202
+ }
1203
+ balanceStrategyCache.delete(key);
1204
+ }
1205
+
1206
+ for (const baseUrl of bases) {
1207
+ const result = await probeBalanceFromBase(baseUrl, channel, token);
1208
+ if (result?.snapshot?.visible) {
1209
+ balanceStrategyCache.set(key, result.strategy);
1210
+ balanceCache.set(key, {
1211
+ snapshot: result.snapshot,
1212
+ expiresAt: currentTime + CACHE_TTL_MS
1213
+ });
1214
+ return result.snapshot;
1215
+ }
1216
+ lastSnapshot = result?.snapshot || makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
1217
+ }
1218
+
1219
+ if (cached?.snapshot?.visible) {
1220
+ const staleSnapshot = {
1221
+ ...cached.snapshot,
1222
+ stale: true
1223
+ };
1224
+ balanceCache.set(key, {
1225
+ snapshot: staleSnapshot,
1226
+ expiresAt: currentTime + CACHE_TTL_MS
1227
+ });
1228
+ return staleSnapshot;
1229
+ }
1230
+
1231
+ return lastSnapshot || makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
1232
+ }
1233
+
1234
+ function getChannelsForSource(source) {
1235
+ if (source === 'claude') {
1236
+ return require('./channels').getAllChannels() || [];
1237
+ }
1238
+ if (source === 'codex') {
1239
+ return require('./codex-channels').getChannels().channels || [];
1240
+ }
1241
+ if (source === 'gemini') {
1242
+ return require('./gemini-channels').getChannels().channels || [];
1243
+ }
1244
+ if (source === 'opencode') {
1245
+ return require('./opencode-channels').getChannels().channels || [];
1246
+ }
1247
+ return [];
1248
+ }
1249
+
1250
+ function isBalanceDisplayEnabled() {
1251
+ try {
1252
+ return loadUIConfig().channelBalance?.showRemaining === true;
1253
+ } catch {
1254
+ return false;
1255
+ }
1256
+ }
1257
+
1258
+ function validateSource(source) {
1259
+ const normalized = String(source || '').trim().toLowerCase();
1260
+ if (!VALID_SOURCES.has(normalized)) {
1261
+ throw new Error('Invalid channel balance source');
1262
+ }
1263
+ return normalized;
1264
+ }
1265
+
1266
+ async function mapWithConcurrency(items, limit, worker) {
1267
+ const results = new Array(items.length);
1268
+ let nextIndex = 0;
1269
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1270
+ while (nextIndex < items.length) {
1271
+ const index = nextIndex;
1272
+ nextIndex += 1;
1273
+ results[index] = await worker(items[index], index);
1274
+ }
1275
+ });
1276
+ await Promise.all(workers);
1277
+ return results;
1278
+ }
1279
+
1280
+ async function getChannelBalances(source, options = {}) {
1281
+ const normalizedSource = validateSource(source);
1282
+ if (!isBalanceDisplayEnabled()) {
1283
+ return { enabled: false, source: normalizedSource, balances: {} };
1284
+ }
1285
+
1286
+ const channels = getChannelsForSource(normalizedSource);
1287
+ const entries = await mapWithConcurrency(channels, 4, async (channel) => {
1288
+ const snapshot = await refreshChannelBalanceSnapshot(normalizedSource, channel, options);
1289
+ return [channel.id, snapshot];
1290
+ });
1291
+
1292
+ return {
1293
+ enabled: true,
1294
+ source: normalizedSource,
1295
+ balances: Object.fromEntries(entries.filter(([id]) => id))
1296
+ };
1297
+ }
1298
+
1299
+ async function refreshChannelBalance(source, channelId) {
1300
+ const normalizedSource = validateSource(source);
1301
+ if (!isBalanceDisplayEnabled()) {
1302
+ return { enabled: false, source: normalizedSource, channelId, balance: makeHiddenSnapshot() };
1303
+ }
1304
+
1305
+ const channels = getChannelsForSource(normalizedSource);
1306
+ const channel = channels.find(item => String(item.id) === String(channelId));
1307
+ if (!channel) {
1308
+ const error = new Error('Channel not found');
1309
+ error.statusCode = 404;
1310
+ throw error;
1311
+ }
1312
+
1313
+ const balance = await refreshChannelBalanceSnapshot(normalizedSource, channel, { force: true });
1314
+ return {
1315
+ enabled: true,
1316
+ source: normalizedSource,
1317
+ channelId,
1318
+ balance
1319
+ };
1320
+ }
1321
+
1322
+ function clearBalanceCache() {
1323
+ balanceCache.clear();
1324
+ balanceStrategyCache.clear();
1325
+ }
1326
+
1327
+ module.exports = {
1328
+ getChannelBalances,
1329
+ refreshChannelBalance,
1330
+ _test: {
1331
+ clearBalanceCache,
1332
+ normalizeBaseUrl,
1333
+ buildBaseCandidates,
1334
+ buildHubBalanceSnapshot,
1335
+ buildNewApiTokenUsageSnapshot,
1336
+ buildOpenRouterKeySnapshot,
1337
+ buildOpenRouterCreditsSnapshot,
1338
+ buildSiliconFlowUserInfoSnapshot,
1339
+ detectPlatformByUrlHint,
1340
+ isSub2ApiUsagePayload,
1341
+ buildSub2ApiUsageSnapshot,
1342
+ resolveStatusPlatform,
1343
+ resolveBalanceToken,
1344
+ build88CodeApiBaseCandidates,
1345
+ buildOpenRouterApiBaseCandidates,
1346
+ buildSiliconFlowApiBaseCandidates,
1347
+ build88CodeUsageSnapshot,
1348
+ build88CodeSubscriptionSnapshot,
1349
+ buildBalanceProbeStrategies,
1350
+ runBalanceStrategy,
1351
+ refreshChannelBalanceSnapshot,
1352
+ makeVisibleSnapshot,
1353
+ requestJson
1354
+ }
1355
+ };