coding-tool-x 3.6.1 → 3.6.3

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 (31) hide show
  1. package/dist/web/assets/{Analytics-DvWZOZ13.js → Analytics-D0BDj7bo.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-DiuKu3LA.js → ConfigTemplates-BnwWmyV7.js} +1 -1
  3. package/dist/web/assets/{Home-DeGTc9uf.js → Home-Yw5bTgrH.js} +1 -1
  4. package/dist/web/assets/{PluginManager-CljkN0Rb.js → PluginManager-CRdRLKsS.js} +1 -1
  5. package/dist/web/assets/{ProjectList-CFQJaSQ8.js → ProjectList-CDIYAOyV.js} +1 -1
  6. package/dist/web/assets/SessionList-BJ2kbl-3.js +1 -0
  7. package/dist/web/assets/{SkillManager-DLhg11LF.js → SkillManager-DaM0Ob6l.js} +1 -1
  8. package/dist/web/assets/{WorkspaceManager-DXB4kE9p.js → WorkspaceManager-Y-EZ7NxE.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-QeTxUv96.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/config/default.js +3 -0
  18. package/src/config/model-metadata.json +77 -34
  19. package/src/server/api/channel-balances.js +36 -0
  20. package/src/server/codex-proxy-server.js +4 -10
  21. package/src/server/gemini-proxy-server.js +9 -12
  22. package/src/server/index.js +1 -0
  23. package/src/server/opencode-proxy-server.js +4 -10
  24. package/src/server/services/channel-balance.js +562 -0
  25. package/src/server/services/gemini-sessions.js +5 -2
  26. package/src/server/services/model-detector.js +7 -0
  27. package/src/server/services/ui-config.js +4 -0
  28. package/src/server/utils/pricing.js +26 -1
  29. package/dist/web/assets/SessionList-Fq4uQSQp.js +0 -1
  30. package/dist/web/assets/index-CLra7uzu.js +0 -2
  31. package/dist/web/assets/index-dzKgnRhx.css +0 -1
@@ -14,7 +14,7 @@ const { recordSuccess, recordFailure } = require('./services/channel-health');
14
14
  const { loadConfig } = require('../config/loader');
15
15
  const DEFAULT_CONFIG = require('../config/default');
16
16
  const { PATHS, ensureStorageDirMigrated } = require('../config/paths');
17
- const { resolveModelPricing } = require('./utils/pricing');
17
+ const { resolveModelPricing, calculateTokenCost } = require('./utils/pricing');
18
18
  const { recordRequest: recordOpenCodeRequest } = require('./services/opencode-statistics-service');
19
19
  const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
20
20
  const { getEnabledChannels, getEffectiveApiKey } = require('./services/opencode-channels');
@@ -58,7 +58,6 @@ const PRICING = {
58
58
  };
59
59
 
60
60
  const OPENCODE_BASE_PRICING = DEFAULT_CONFIG.pricing.opencode || DEFAULT_CONFIG.pricing.codex;
61
- const ONE_MILLION = 1000000;
62
61
  const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.59 (external, cli)';
63
62
  const CLAUDE_MESSAGES_BETA_FLAGS = Object.freeze([
64
63
  'claude-code-20250219',
@@ -133,13 +132,7 @@ function calculateCost(model, tokens) {
133
132
  }
134
133
 
135
134
  const pricing = resolveModelPricing('opencode', model, fallbackPricing, OPENCODE_BASE_PRICING);
136
- const inputRate = typeof pricing.input === 'number' ? pricing.input : OPENCODE_BASE_PRICING.input;
137
- const outputRate = typeof pricing.output === 'number' ? pricing.output : OPENCODE_BASE_PRICING.output;
138
-
139
- return (
140
- (tokens.input || 0) * inputRate / ONE_MILLION +
141
- (tokens.output || 0) * outputRate / ONE_MILLION
142
- );
135
+ return calculateTokenCost(pricing, tokens, OPENCODE_BASE_PRICING);
143
136
  }
144
137
 
145
138
  const jsonBodyParser = express.json({
@@ -4741,5 +4734,6 @@ module.exports = {
4741
4734
  stopOpenCodeProxyServer,
4742
4735
  getOpenCodeProxyStatus,
4743
4736
  clearOpenCodeRedirectCache,
4744
- collectProxyModelList
4737
+ collectProxyModelList,
4738
+ calculateCost
4745
4739
  };
@@ -0,0 +1,562 @@
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
+
14
+ const balanceCache = new Map();
15
+
16
+ function nowIso(now = Date.now()) {
17
+ return new Date(now).toISOString();
18
+ }
19
+
20
+ function stripBearer(value) {
21
+ const token = String(value || '').trim();
22
+ return token.toLowerCase().startsWith('bearer ') ? token.slice(7).trim() : token;
23
+ }
24
+
25
+ function buildAuthHeaders(token, extra = {}) {
26
+ return {
27
+ Accept: 'application/json',
28
+ Authorization: `Bearer ${stripBearer(token)}`,
29
+ ...extra
30
+ };
31
+ }
32
+
33
+ function parseFiniteNumber(value) {
34
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
35
+ if (typeof value === 'string') {
36
+ const parsed = Number(value.trim());
37
+ if (Number.isFinite(parsed)) return parsed;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function roundMoney(value) {
43
+ if (!Number.isFinite(value)) return value;
44
+ return Math.round(value * 1000000) / 1000000;
45
+ }
46
+
47
+ function formatMoney(value) {
48
+ if (!Number.isFinite(value)) return '';
49
+ return `$${value.toFixed(2)}`;
50
+ }
51
+
52
+ function makeHiddenSnapshot(platform = null, { stale = false, updatedAt = nowIso() } = {}) {
53
+ return {
54
+ visible: false,
55
+ platform,
56
+ updatedAt,
57
+ stale
58
+ };
59
+ }
60
+
61
+ function makeVisibleSnapshot(platform, input, { stale = false, updatedAt = nowIso() } = {}) {
62
+ const remaining = parseFiniteNumber(input.remaining);
63
+ const monthlyRemaining = parseFiniteNumber(input.monthlyRemaining);
64
+ if (remaining == null && monthlyRemaining == null) {
65
+ return makeHiddenSnapshot(platform, { stale, updatedAt });
66
+ }
67
+
68
+ const labelValue = remaining != null ? remaining : monthlyRemaining;
69
+ const labelPrefix = remaining != null ? '余额' : '月余';
70
+ const snapshot = {
71
+ visible: true,
72
+ platform,
73
+ label: `${labelPrefix} ${formatMoney(labelValue)}`,
74
+ updatedAt,
75
+ stale
76
+ };
77
+
78
+ if (remaining != null) snapshot.remaining = roundMoney(remaining);
79
+ const used = parseFiniteNumber(input.used);
80
+ if (used != null) snapshot.used = roundMoney(used);
81
+ const total = parseFiniteNumber(input.total);
82
+ if (total != null) snapshot.total = roundMoney(total);
83
+ if (monthlyRemaining != null) snapshot.monthlyRemaining = roundMoney(monthlyRemaining);
84
+ return snapshot;
85
+ }
86
+
87
+ function normalizeBaseUrl(value) {
88
+ const raw = String(value || '').trim();
89
+ if (!raw) return '';
90
+
91
+ let parsed;
92
+ try {
93
+ parsed = new URL(raw);
94
+ } catch {
95
+ return '';
96
+ }
97
+
98
+ parsed.hash = '';
99
+ parsed.search = '';
100
+ let pathname = parsed.pathname || '/';
101
+ pathname = pathname.replace(/\/+$/, '') || '/';
102
+ const lower = pathname.toLowerCase();
103
+ const suffixes = [
104
+ '/v1/chat/completions',
105
+ '/v1/responses',
106
+ '/v1/messages',
107
+ '/v1beta/models',
108
+ '/chat/completions',
109
+ '/responses',
110
+ '/messages',
111
+ '/v1beta',
112
+ '/v1'
113
+ ];
114
+
115
+ for (const suffix of suffixes) {
116
+ if (lower === suffix || lower.endsWith(suffix)) {
117
+ pathname = pathname.slice(0, pathname.length - suffix.length) || '/';
118
+ break;
119
+ }
120
+ }
121
+
122
+ parsed.pathname = pathname.replace(/\/+$/, '') || '/';
123
+ return parsed.toString().replace(/\/$/, '');
124
+ }
125
+
126
+ function buildBaseCandidates(baseUrl) {
127
+ const normalized = normalizeBaseUrl(baseUrl);
128
+ if (!normalized) return [];
129
+
130
+ const candidates = [normalized];
131
+ try {
132
+ const parsed = new URL(normalized);
133
+ const origin = parsed.origin;
134
+ if (origin && !candidates.includes(origin)) {
135
+ candidates.push(origin);
136
+ }
137
+ } catch {}
138
+ return candidates;
139
+ }
140
+
141
+ function joinUrl(baseUrl, endpoint) {
142
+ const base = String(baseUrl || '').replace(/\/+$/, '');
143
+ const path = String(endpoint || '').startsWith('/') ? endpoint : `/${endpoint}`;
144
+ return `${base}${path}`;
145
+ }
146
+
147
+ function requestJson(url, { headers = {}, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
148
+ return new Promise((resolve, reject) => {
149
+ let parsed;
150
+ try {
151
+ parsed = new URL(url);
152
+ } catch (error) {
153
+ reject(error);
154
+ return;
155
+ }
156
+
157
+ const client = parsed.protocol === 'http:' ? http : https;
158
+ const req = client.request({
159
+ protocol: parsed.protocol,
160
+ hostname: parsed.hostname,
161
+ port: parsed.port,
162
+ path: `${parsed.pathname}${parsed.search}`,
163
+ method: 'GET',
164
+ headers
165
+ }, (res) => {
166
+ const chunks = [];
167
+ let total = 0;
168
+
169
+ res.on('data', (chunk) => {
170
+ total += chunk.length;
171
+ if (total > MAX_RESPONSE_BYTES) {
172
+ req.destroy(new Error('response too large'));
173
+ return;
174
+ }
175
+ chunks.push(chunk);
176
+ });
177
+
178
+ res.on('end', () => {
179
+ const raw = Buffer.concat(chunks).toString('utf8');
180
+ if (res.statusCode < 200 || res.statusCode >= 300) {
181
+ const error = new Error(`HTTP ${res.statusCode}`);
182
+ error.statusCode = res.statusCode;
183
+ reject(error);
184
+ return;
185
+ }
186
+ try {
187
+ resolve(raw ? JSON.parse(raw) : null);
188
+ } catch (error) {
189
+ reject(error);
190
+ }
191
+ });
192
+ });
193
+
194
+ req.setTimeout(timeoutMs, () => {
195
+ req.destroy(new Error('request timeout'));
196
+ });
197
+ req.on('error', reject);
198
+ req.end();
199
+ });
200
+ }
201
+
202
+ async function tryRequestJson(url, options) {
203
+ try {
204
+ return await requestJson(url, options);
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ function detectPlatformByUrlHint(baseUrl) {
211
+ const value = String(baseUrl || '').toLowerCase();
212
+ if (value.includes('sub2api')) return 'sub2api';
213
+ if (value.includes('anyrouter')) return 'anyrouter';
214
+ if (value.includes('donehub') || value.includes('done-hub')) return 'done-hub';
215
+ if (value.includes('onehub') || value.includes('one-hub')) return 'one-hub';
216
+ if (value.includes('veloera')) return 'veloera';
217
+ if (value.includes('oneapi') || value.includes('one-api')) return 'one-api';
218
+ if (value.includes('newapi') || value.includes('new-api')) return 'new-api';
219
+ return null;
220
+ }
221
+
222
+ function resolveStatusPlatform(payload, hintedPlatform = null) {
223
+ if (!payload || payload.success !== true) return null;
224
+ const systemName = typeof payload?.data?.system_name === 'string'
225
+ ? payload.data.system_name.toLowerCase()
226
+ : '';
227
+ const version = typeof payload?.data?.version === 'string'
228
+ ? payload.data.version.toLowerCase()
229
+ : '';
230
+
231
+ if (hintedPlatform === 'anyrouter') return 'anyrouter';
232
+ if (hintedPlatform === 'done-hub') return 'done-hub';
233
+ if (hintedPlatform === 'one-hub') return 'one-hub';
234
+ if (systemName.includes('veloera') || version.includes('veloera')) return 'veloera';
235
+ if (systemName) return hintedPlatform === 'one-api' ? 'one-api' : 'new-api';
236
+ return hintedPlatform || 'one-api';
237
+ }
238
+
239
+ function extractSub2ApiData(payload) {
240
+ if (!payload || typeof payload !== 'object') return null;
241
+ if (typeof payload.code === 'number') {
242
+ if (payload.code !== 0 || payload.data === undefined) return null;
243
+ return payload.data;
244
+ }
245
+ if (payload.data && typeof payload.data === 'object') return payload.data;
246
+ return payload;
247
+ }
248
+
249
+ async function detectPlatform(baseUrl, token, hintedPlatform = null) {
250
+ const headers = buildAuthHeaders(token);
251
+
252
+ if (hintedPlatform === 'sub2api') {
253
+ return 'sub2api';
254
+ }
255
+
256
+ const status = await tryRequestJson(joinUrl(baseUrl, '/api/status'), { headers: { Accept: 'application/json' } });
257
+ const statusPlatform = resolveStatusPlatform(status, hintedPlatform);
258
+ if (statusPlatform) return statusPlatform;
259
+
260
+ const sub2Payload = await tryRequestJson(joinUrl(baseUrl, '/api/v1/auth/me'), { headers });
261
+ const sub2Data = extractSub2ApiData(sub2Payload);
262
+ if (sub2Data && Object.prototype.hasOwnProperty.call(sub2Data, 'balance')) {
263
+ return 'sub2api';
264
+ }
265
+
266
+ return hintedPlatform;
267
+ }
268
+
269
+ function extractUserSelfData(payload) {
270
+ if (!payload || typeof payload !== 'object') return null;
271
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
272
+ if (!data || typeof data !== 'object') return null;
273
+ if (!Object.prototype.hasOwnProperty.call(data, 'quota')) return null;
274
+ return data;
275
+ }
276
+
277
+ function buildHubBalanceSnapshot(platform, payload) {
278
+ const data = extractUserSelfData(payload);
279
+ if (!data) return makeHiddenSnapshot(platform);
280
+
281
+ const unit = platform === 'veloera' ? VELOERA_QUOTA_UNIT : QUOTA_UNIT;
282
+ const quotaRaw = parseFiniteNumber(data.quota);
283
+ const usedRaw = parseFiniteNumber(data.used_quota);
284
+ if (quotaRaw == null) return makeHiddenSnapshot(platform);
285
+
286
+ const quota = quotaRaw / unit;
287
+ const used = (usedRaw || 0) / unit;
288
+ let remaining;
289
+ let total;
290
+
291
+ if (platform === 'new-api' || platform === 'anyrouter' || platform === 'done-hub') {
292
+ remaining = quota;
293
+ total = quota + used;
294
+ } else {
295
+ remaining = quota - used;
296
+ total = quota;
297
+ }
298
+
299
+ return makeVisibleSnapshot(platform, {
300
+ remaining,
301
+ used,
302
+ total
303
+ });
304
+ }
305
+
306
+ function parseSubscriptionItems(payload) {
307
+ const data = extractSub2ApiData(payload);
308
+ const candidates = [
309
+ data?.subscriptions,
310
+ data?.items,
311
+ data?.data,
312
+ payload?.subscriptions,
313
+ payload?.items,
314
+ Array.isArray(data) ? data : null
315
+ ];
316
+ for (const candidate of candidates) {
317
+ if (Array.isArray(candidate)) return candidate;
318
+ }
319
+ return [];
320
+ }
321
+
322
+ function readMoneyField(item, ...keys) {
323
+ for (const key of keys) {
324
+ const value = parseFiniteNumber(item?.[key]);
325
+ if (value != null) return value;
326
+ }
327
+ return null;
328
+ }
329
+
330
+ async function fetchSub2ApiSubscriptionSummary(baseUrl, headers) {
331
+ const endpoints = ['/api/v1/subscriptions/summary', '/api/v1/subscriptions/active'];
332
+ for (const endpoint of endpoints) {
333
+ const payload = await tryRequestJson(joinUrl(baseUrl, endpoint), { headers });
334
+ if (!payload) continue;
335
+ const items = parseSubscriptionItems(payload);
336
+ if (!items.length) continue;
337
+
338
+ let monthlyRemaining = 0;
339
+ let hasMonthlyLimit = false;
340
+ for (const item of items) {
341
+ const used = readMoneyField(item, 'monthlyUsedUsd', 'monthly_used_usd', 'monthly_used', 'used_monthly_usd') || 0;
342
+ const limit = readMoneyField(item, 'monthlyLimitUsd', 'monthly_limit_usd', 'monthly_limit', 'limit_monthly_usd');
343
+ if (limit == null) continue;
344
+ hasMonthlyLimit = true;
345
+ monthlyRemaining += Math.max(0, limit - used);
346
+ }
347
+
348
+ if (hasMonthlyLimit) {
349
+ return { monthlyRemaining };
350
+ }
351
+ }
352
+ return {};
353
+ }
354
+
355
+ async function fetchSub2ApiBalance(baseUrl, token) {
356
+ const headers = buildAuthHeaders(token);
357
+ const payload = await requestJson(joinUrl(baseUrl, '/api/v1/auth/me'), { headers });
358
+ const data = extractSub2ApiData(payload);
359
+ const balance = parseFiniteNumber(data?.balance);
360
+ if (balance == null) return makeHiddenSnapshot('sub2api');
361
+
362
+ const summary = await fetchSub2ApiSubscriptionSummary(baseUrl, headers);
363
+ return makeVisibleSnapshot('sub2api', {
364
+ remaining: balance,
365
+ used: 0,
366
+ total: balance,
367
+ monthlyRemaining: summary.monthlyRemaining
368
+ });
369
+ }
370
+
371
+ async function fetchHubBalance(baseUrl, token, platform) {
372
+ const headers = buildAuthHeaders(token);
373
+ const payload = await requestJson(joinUrl(baseUrl, '/api/user/self'), { headers });
374
+ return buildHubBalanceSnapshot(platform, payload);
375
+ }
376
+
377
+ async function fetchBalanceFromBase(baseUrl, channel) {
378
+ const token = stripBearer(channel.apiKey || channel.token || channel.authToken || '');
379
+ if (!baseUrl || !token) return makeHiddenSnapshot();
380
+
381
+ const hintedPlatform = detectPlatformByUrlHint(channel.baseUrl || baseUrl);
382
+ const platform = await detectPlatform(baseUrl, token, hintedPlatform);
383
+ if (!platform) return makeHiddenSnapshot();
384
+
385
+ if (platform === 'sub2api') {
386
+ return fetchSub2ApiBalance(baseUrl, token);
387
+ }
388
+
389
+ if (['new-api', 'one-api', 'one-hub', 'done-hub', 'veloera', 'anyrouter'].includes(platform)) {
390
+ return fetchHubBalance(baseUrl, token, platform);
391
+ }
392
+
393
+ return makeHiddenSnapshot(platform);
394
+ }
395
+
396
+ function buildCacheKey(source, channel) {
397
+ const token = stripBearer(channel.apiKey || channel.token || channel.authToken || '');
398
+ const tokenHash = token
399
+ ? crypto.createHash('sha256').update(token).digest('hex').slice(0, 16)
400
+ : 'no-token';
401
+ return [
402
+ source,
403
+ channel.id || channel.name || 'unknown',
404
+ normalizeBaseUrl(channel.baseUrl || ''),
405
+ tokenHash
406
+ ].join(':');
407
+ }
408
+
409
+ async function refreshChannelBalanceSnapshot(source, channel, options = {}) {
410
+ const force = options.force === true;
411
+ const currentTime = options.now || Date.now();
412
+ const key = buildCacheKey(source, channel);
413
+ const cached = balanceCache.get(key);
414
+
415
+ if (!force && cached && cached.expiresAt > currentTime) {
416
+ return cached.snapshot;
417
+ }
418
+
419
+ const bases = buildBaseCandidates(channel.baseUrl);
420
+ if (!bases.length || !stripBearer(channel.apiKey || channel.token || channel.authToken || '')) {
421
+ return makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
422
+ }
423
+
424
+ let lastSnapshot = null;
425
+ for (const baseUrl of bases) {
426
+ try {
427
+ const snapshot = await fetchBalanceFromBase(baseUrl, channel);
428
+ if (snapshot?.visible) {
429
+ balanceCache.set(key, {
430
+ snapshot,
431
+ expiresAt: currentTime + CACHE_TTL_MS
432
+ });
433
+ return snapshot;
434
+ }
435
+ lastSnapshot = snapshot || lastSnapshot;
436
+ } catch {
437
+ // Try the next normalized base candidate.
438
+ }
439
+ }
440
+
441
+ if (cached?.snapshot?.visible) {
442
+ const staleSnapshot = {
443
+ ...cached.snapshot,
444
+ stale: true
445
+ };
446
+ balanceCache.set(key, {
447
+ snapshot: staleSnapshot,
448
+ expiresAt: currentTime + CACHE_TTL_MS
449
+ });
450
+ return staleSnapshot;
451
+ }
452
+
453
+ return lastSnapshot || makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
454
+ }
455
+
456
+ function getChannelsForSource(source) {
457
+ if (source === 'claude') {
458
+ return require('./channels').getAllChannels() || [];
459
+ }
460
+ if (source === 'codex') {
461
+ return require('./codex-channels').getChannels().channels || [];
462
+ }
463
+ if (source === 'gemini') {
464
+ return require('./gemini-channels').getChannels().channels || [];
465
+ }
466
+ if (source === 'opencode') {
467
+ return require('./opencode-channels').getChannels().channels || [];
468
+ }
469
+ return [];
470
+ }
471
+
472
+ function isBalanceDisplayEnabled() {
473
+ try {
474
+ return loadUIConfig().channelBalance?.showRemaining === true;
475
+ } catch {
476
+ return false;
477
+ }
478
+ }
479
+
480
+ function validateSource(source) {
481
+ const normalized = String(source || '').trim().toLowerCase();
482
+ if (!VALID_SOURCES.has(normalized)) {
483
+ throw new Error('Invalid channel balance source');
484
+ }
485
+ return normalized;
486
+ }
487
+
488
+ async function mapWithConcurrency(items, limit, worker) {
489
+ const results = new Array(items.length);
490
+ let nextIndex = 0;
491
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
492
+ while (nextIndex < items.length) {
493
+ const index = nextIndex;
494
+ nextIndex += 1;
495
+ results[index] = await worker(items[index], index);
496
+ }
497
+ });
498
+ await Promise.all(workers);
499
+ return results;
500
+ }
501
+
502
+ async function getChannelBalances(source, options = {}) {
503
+ const normalizedSource = validateSource(source);
504
+ if (!isBalanceDisplayEnabled()) {
505
+ return { enabled: false, source: normalizedSource, balances: {} };
506
+ }
507
+
508
+ const channels = getChannelsForSource(normalizedSource);
509
+ const entries = await mapWithConcurrency(channels, 4, async (channel) => {
510
+ const snapshot = await refreshChannelBalanceSnapshot(normalizedSource, channel, options);
511
+ return [channel.id, snapshot];
512
+ });
513
+
514
+ return {
515
+ enabled: true,
516
+ source: normalizedSource,
517
+ balances: Object.fromEntries(entries.filter(([id]) => id))
518
+ };
519
+ }
520
+
521
+ async function refreshChannelBalance(source, channelId) {
522
+ const normalizedSource = validateSource(source);
523
+ if (!isBalanceDisplayEnabled()) {
524
+ return { enabled: false, source: normalizedSource, channelId, balance: makeHiddenSnapshot() };
525
+ }
526
+
527
+ const channels = getChannelsForSource(normalizedSource);
528
+ const channel = channels.find(item => String(item.id) === String(channelId));
529
+ if (!channel) {
530
+ const error = new Error('Channel not found');
531
+ error.statusCode = 404;
532
+ throw error;
533
+ }
534
+
535
+ const balance = await refreshChannelBalanceSnapshot(normalizedSource, channel, { force: true });
536
+ return {
537
+ enabled: true,
538
+ source: normalizedSource,
539
+ channelId,
540
+ balance
541
+ };
542
+ }
543
+
544
+ function clearBalanceCache() {
545
+ balanceCache.clear();
546
+ }
547
+
548
+ module.exports = {
549
+ getChannelBalances,
550
+ refreshChannelBalance,
551
+ _test: {
552
+ clearBalanceCache,
553
+ normalizeBaseUrl,
554
+ buildBaseCandidates,
555
+ buildHubBalanceSnapshot,
556
+ detectPlatformByUrlHint,
557
+ resolveStatusPlatform,
558
+ refreshChannelBalanceSnapshot,
559
+ makeVisibleSnapshot,
560
+ requestJson
561
+ }
562
+ };
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const { HOME_DIR } = require('../../config/paths');
5
5
  const { getGeminiDir } = require('./gemini-config');
6
+ const { resolveModelPricing } = require('../utils/pricing');
6
7
 
7
8
  // 路径映射缓存
8
9
  let pathMappingCache = null;
@@ -228,8 +229,10 @@ function readSessionMeta(filePath) {
228
229
  model = msg.model;
229
230
  const inputTokens = msg.tokens.input || 0;
230
231
  const outputTokens = msg.tokens.output || 0;
231
- // gemini-2.5-pro: $1.25 / 1M input, $5 / 1M output
232
- totalCost += (inputTokens * 1.25 / 1000000) + (outputTokens * 5 / 1000000);
232
+ const pricing = resolveModelPricing('gemini', msg.model);
233
+ const inputRate = typeof pricing.input === 'number' ? pricing.input : 1.25;
234
+ const outputRate = typeof pricing.output === 'number' ? pricing.output : 10;
235
+ totalCost += (inputTokens * inputRate / 1000000) + (outputTokens * outputRate / 1000000);
233
236
  }
234
237
  }
235
238
  });
@@ -16,6 +16,7 @@ const { PATHS } = require('../../config/paths');
16
16
  // 内置模型优先级(当配置缺失时兜底)
17
17
  const MODEL_PRIORITY = {
18
18
  claude: [
19
+ 'claude-opus-4-7',
19
20
  'claude-opus-4-6',
20
21
  'claude-sonnet-4-6',
21
22
  'claude-opus-4-5-20251101',
@@ -23,7 +24,11 @@ const MODEL_PRIORITY = {
23
24
  'claude-haiku-4-5-20251001'
24
25
  ],
25
26
  codex: [
27
+ 'gpt-5.5',
26
28
  'gpt-5.4',
29
+ 'gpt-5.4-mini',
30
+ 'gpt-5.3-codex',
31
+ 'gpt-5.3-codex-spark',
27
32
  'gpt-5.2-codex',
28
33
  'gpt-5.1-codex-max',
29
34
  'gpt-5.1-codex',
@@ -34,6 +39,8 @@ const MODEL_PRIORITY = {
34
39
  'gpt-5'
35
40
  ],
36
41
  gemini: [
42
+ 'gemini-3.1-pro-preview',
43
+ 'gemini-3.1-flash-preview',
37
44
  'gemini-3-pro-preview',
38
45
  'gemini-3-flash-preview',
39
46
  'gemini-2.5-pro',
@@ -11,6 +11,9 @@ const DEFAULT_UI_CONFIG = {
11
11
  showChannels: true,
12
12
  showLogs: true
13
13
  },
14
+ channelBalance: {
15
+ showRemaining: false
16
+ },
14
17
  channelLocks: {
15
18
  claude: false,
16
19
  codex: false,
@@ -57,6 +60,7 @@ function readUIConfigFromFile() {
57
60
  ...data,
58
61
  theme: data.theme || DEFAULT_UI_CONFIG.theme,
59
62
  panelVisibility: { ...DEFAULT_UI_CONFIG.panelVisibility, ...data.panelVisibility },
63
+ channelBalance: { ...DEFAULT_UI_CONFIG.channelBalance, ...data.channelBalance },
60
64
  channelLocks: { ...DEFAULT_UI_CONFIG.channelLocks, ...data.channelLocks },
61
65
  channelCollapse: { ...DEFAULT_UI_CONFIG.channelCollapse, ...data.channelCollapse },
62
66
  channelOrder: { ...DEFAULT_UI_CONFIG.channelOrder, ...data.channelOrder }
@@ -67,7 +67,32 @@ function resolveModelPricing(_toolKey, model, fallbackPricing = {}, defaultPrici
67
67
  };
68
68
  }
69
69
 
70
+ function getRate(pricing, defaultPricing, key) {
71
+ if (typeof pricing?.[key] === 'number') return pricing[key];
72
+ if (typeof defaultPricing?.[key] === 'number') return defaultPricing[key];
73
+ return 0;
74
+ }
75
+
76
+ function calculateTokenCost(pricing = {}, tokens = {}, defaultPricing = {}) {
77
+ const inputRate = getRate(pricing, defaultPricing, 'input');
78
+ const outputRate = getRate(pricing, defaultPricing, 'output');
79
+ const cacheCreationRate = getRate(pricing, defaultPricing, 'cacheCreation');
80
+ const cacheReadRate = getRate(pricing, defaultPricing, 'cacheRead');
81
+ const inputTokens = Number(tokens.input || 0);
82
+ const outputTokens = Number(tokens.output || 0);
83
+ const cacheCreationTokens = Number(tokens.cacheCreation || 0);
84
+ const cacheReadTokens = Number(tokens.cacheRead || tokens.cached || 0);
85
+
86
+ return (
87
+ inputTokens * inputRate / 1000000 +
88
+ outputTokens * outputRate / 1000000 +
89
+ cacheCreationTokens * cacheCreationRate / 1000000 +
90
+ cacheReadTokens * cacheReadRate / 1000000
91
+ );
92
+ }
93
+
70
94
  module.exports = {
71
95
  resolvePricing,
72
- resolveModelPricing
96
+ resolveModelPricing,
97
+ calculateTokenCost
73
98
  };