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.
- package/dist/web/assets/{Analytics-DvWZOZ13.js → Analytics-D0BDj7bo.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-DiuKu3LA.js → ConfigTemplates-BnwWmyV7.js} +1 -1
- package/dist/web/assets/{Home-DeGTc9uf.js → Home-Yw5bTgrH.js} +1 -1
- package/dist/web/assets/{PluginManager-CljkN0Rb.js → PluginManager-CRdRLKsS.js} +1 -1
- package/dist/web/assets/{ProjectList-CFQJaSQ8.js → ProjectList-CDIYAOyV.js} +1 -1
- package/dist/web/assets/SessionList-BJ2kbl-3.js +1 -0
- package/dist/web/assets/{SkillManager-DLhg11LF.js → SkillManager-DaM0Ob6l.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-DXB4kE9p.js → WorkspaceManager-Y-EZ7NxE.js} +1 -1
- package/dist/web/assets/{icons-QFXzEgqM.js → icons-U50_Cfbe.js} +1 -1
- package/dist/web/assets/index-BkLQex1R.css +1 -0
- package/dist/web/assets/index-QeTxUv96.js +2 -0
- package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-Cjh6GLto.js} +1 -1
- package/dist/web/assets/{vendors-DQA4Hw9v.js → vendors--KP7Ux0s.js} +1 -1
- package/dist/web/assets/{vue-vendor-B5RqjEjQ.js → vue-vendor-C5aMkRmm.js} +1 -1
- package/dist/web/index.html +6 -6
- package/package.json +1 -1
- package/src/config/default.js +3 -0
- package/src/config/model-metadata.json +77 -34
- package/src/server/api/channel-balances.js +36 -0
- package/src/server/codex-proxy-server.js +4 -10
- package/src/server/gemini-proxy-server.js +9 -12
- package/src/server/index.js +1 -0
- package/src/server/opencode-proxy-server.js +4 -10
- package/src/server/services/channel-balance.js +562 -0
- package/src/server/services/gemini-sessions.js +5 -2
- package/src/server/services/model-detector.js +7 -0
- package/src/server/services/ui-config.js +4 -0
- package/src/server/utils/pricing.js +26 -1
- package/dist/web/assets/SessionList-Fq4uQSQp.js +0 -1
- package/dist/web/assets/index-CLra7uzu.js +0 -2
- 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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
};
|