liuw15 commited on
Commit
3c5c286
·
1 Parent(s): d17c19c

增加token计数,增加前端额度查询,更新readme

Browse files
QUOTA_FEATURE.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 模型额度管理功能
2
+
3
+ ## 功能说明
4
+
5
+ 新增了模型额度查看功能,可以在前端管理界面查看每个 Token 对应的模型剩余额度和重置时间。
6
+
7
+ ## 实现方案
8
+
9
+ ### 数据存储
10
+ - **accounts.json**: 保持简洁,只存储核心认证信息
11
+ - **data/quotas.json**: 新建文件,专门存储额度信息(轻量级持久化)
12
+ - **内存缓存**: 5分钟缓存,避免频繁请求API
13
+ - **自动清理**: 每小时清理超过1小时未更新的数据
14
+
15
+ ### 核心文件
16
+
17
+ 1. **src/api/client.js**
18
+ - 新增 `getModelsWithQuotas(token)` 函数
19
+ - 从 API 响应中提取 `quotaInfo` 字段
20
+ - 返回简化的额度数据结构
21
+
22
+ 2. **src/auth/quota_manager.js** (新建)
23
+ - 额度缓存管理
24
+ - 文件持久化
25
+ - UTC 时间转北京时间
26
+ - 自动清理过期数据
27
+
28
+ 3. **src/routes/admin.js**
29
+ - 新增 `GET /admin/tokens/:refreshToken/quotas` 接口
30
+ - 支持按需获取指定 Token 的额度信息
31
+
32
+ 4. **public/app.js**
33
+ - 新增 `toggleQuota()` 函数:展开/收起额度面板
34
+ - 新增 `loadQuota()` 函数:从API加载额度数据
35
+ - 新增 `renderQuota()` 函数:渲染进度条和额度信息
36
+
37
+ 5. **public/style.css**
38
+ - 新增额度展示相关样式
39
+ - 进度条样式(支持颜色渐变:绿色>50%,黄色20-50%,红色<20%)
40
+
41
+ ## 使用方法
42
+
43
+ ### 前端操作
44
+
45
+ 1. 登录管理界面
46
+ 2. 在 Token 卡片中点击 **"📊 查看额度"** 按钮
47
+ 3. 系统会自动加载该 Token 的所有模型额度信息
48
+ 4. 以进度条形式展示:
49
+ - 模型名称
50
+ - 剩余额度百分比(带颜色标识)
51
+ - 额度重置时间(北京时间)
52
+
53
+ ### 数据格式
54
+
55
+ #### API 响应示例
56
+ ```json
57
+ {
58
+ "success": true,
59
+ "data": {
60
+ "lastUpdated": 1765109350660,
61
+ "models": {
62
+ "gemini-2.0-flash-exp": {
63
+ "remaining": 0.972,
64
+ "resetTime": "01-07 15:27",
65
+ "resetTimeRaw": "2025-01-07T07:27:44Z"
66
+ },
67
+ "gemini-1.5-pro": {
68
+ "remaining": 0.85,
69
+ "resetTime": "01-07 16:15",
70
+ "resetTimeRaw": "2025-01-07T08:15:30Z"
71
+ }
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ #### quotas.json 存储格式
78
+ ```json
79
+ {
80
+ "meta": {
81
+ "lastCleanup": 1765109350660,
82
+ "ttl": 3600000
83
+ },
84
+ "quotas": {
85
+ "1//0eDtvmkC_KgZv": {
86
+ "lastUpdated": 1765109350660,
87
+ "models": {
88
+ "gemini-2.0-flash-exp": {
89
+ "r": 0.972,
90
+ "t": "2025-01-07T07:27:44Z"
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ ## 特性
99
+
100
+ ✅ **按需加载**: 只在用户点击时才获取额度信息
101
+ ✅ **智能缓存**: 5分钟内重复查看使用缓存,减少API请求
102
+ ✅ **自动清理**: 定期清理过期数据,保持文件轻量
103
+ ✅ **可视化展示**: 进度条直观显示剩余额度
104
+ ✅ **颜色标识**: 绿色(>50%)、黄色(20-50%)、红色(<20%)
105
+ ✅ **时间转换**: 自动将UTC时间转换为北京时间
106
+ ✅ **轻量存储**: 使用字段缩写,只存储有变化的模型
107
+
108
+ ## 注意事项
109
+
110
+ 1. 首次查看额度时需要调用 Google API,可能需要几秒钟
111
+ 2. 额度信息会缓存5分钟,如需最新数据请等待缓存过期后重新查看
112
+ 3. quotas.json 文件会自动创建,无需手动配置
113
+ 4. 如果 Token 过期或无效,会显示错误提示
114
+
115
+ ## 测试
116
+
117
+ 启动服务后:
118
+ ```bash
119
+ npm start
120
+ ```
121
+
122
+ 访问管理界面,点击任意 Token 的"查看额度"按钮即可测试功能。
README.md CHANGED
@@ -79,6 +79,7 @@ npm start
79
  - 手动填入:直接输入 Access Token 和 Refresh Token
80
  - 🎯 **Token 管理**:
81
  - 查看所有 Token 的详细信息(Access Token 后缀、Project ID、过期时间)
 
82
  - 一键启用/禁用 Token
83
  - 删除无效 Token
84
  - 实时刷新 Token 列表
@@ -109,6 +110,11 @@ npm start
109
 
110
  3. **管理 Token**
111
  - 查看 Token 卡片显示的状态和信息
 
 
 
 
 
112
  - 使用「启用/禁用」按钮控制 Token 状态
113
  - 使用「删除」按钮移除无效 Token
114
  - 点击「刷新」按钮更新列表
 
79
  - 手动填入:直接输入 Access Token 和 Refresh Token
80
  - 🎯 **Token 管理**:
81
  - 查看所有 Token 的详细信息(Access Token 后缀、Project ID、过期时间)
82
+ - 📊 查看模型额度:按类型分组显示(Claude/Gemini/其他),实时查看剩余额度和重置时间
83
  - 一键启用/禁用 Token
84
  - 删除无效 Token
85
  - 实时刷新 Token 列表
 
110
 
111
  3. **管理 Token**
112
  - 查看 Token 卡片显示的状态和信息
113
+ - 点击「📊 查看额度」按钮查看该账号的模型额度信息
114
+ - 自动按模型类型分组(Claude/Gemini/其他)
115
+ - 显示剩余额度百分比和进度条
116
+ - 显示额度重置时间(北京时间)
117
+ - 支持「立即刷新」强制更新额度数据
118
  - 使用「启用/禁用」按钮控制 Token 状态
119
  - 使用「删除」按钮移除无效 Token
120
  - 点击「刷新」按钮更新列表
public/app.js CHANGED
@@ -353,6 +353,7 @@ function renderTokens(tokens) {
353
  </div>
354
  </div>
355
  <div class="token-actions">
 
356
  <button class="btn ${token.enable ? 'btn-warning' : 'btn-success'}" onclick="toggleToken('${token.refresh_token}', ${!token.enable})">
357
  ${token.enable ? '⏸️ 禁用' : '▶️ 启用'}
358
  </button>
@@ -417,6 +418,146 @@ async function deleteToken(refreshToken) {
417
  }
418
  }
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  async function loadConfig() {
421
  try {
422
  const response = await fetch('/admin/config', {
 
353
  </div>
354
  </div>
355
  <div class="token-actions">
356
+ <button class="btn btn-info" onclick="showQuotaModal('${token.refresh_token}')">📊 查看额度</button>
357
  <button class="btn ${token.enable ? 'btn-warning' : 'btn-success'}" onclick="toggleToken('${token.refresh_token}', ${!token.enable})">
358
  ${token.enable ? '⏸️ 禁用' : '▶️ 启用'}
359
  </button>
 
418
  }
419
  }
420
 
421
+ async function showQuotaModal(refreshToken) {
422
+ const modal = document.createElement('div');
423
+ modal.className = 'modal';
424
+ modal.innerHTML = `
425
+ <div class="modal-content" style="max-width: 600px;">
426
+ <div class="modal-title">📊 模型额度信息</div>
427
+ <div id="quotaContent" style="max-height: 60vh; overflow-y: auto;">
428
+ <div class="quota-loading">加载中...</div>
429
+ </div>
430
+ <div class="modal-actions">
431
+ <button class="btn btn-info" onclick="refreshQuotaData('${refreshToken}')">🔄 立即刷新</button>
432
+ <button class="btn btn-secondary" onclick="this.closest('.modal').remove()">关闭</button>
433
+ </div>
434
+ </div>
435
+ `;
436
+ document.body.appendChild(modal);
437
+ modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
438
+
439
+ await loadQuotaData(refreshToken);
440
+ }
441
+
442
+ async function loadQuotaData(refreshToken, forceRefresh = false) {
443
+ const quotaContent = document.getElementById('quotaContent');
444
+ if (!quotaContent) return;
445
+
446
+ const refreshBtn = document.querySelector('.modal-content .btn-info');
447
+ if (refreshBtn) {
448
+ refreshBtn.disabled = true;
449
+ refreshBtn.textContent = '⏳ 加载中...';
450
+ }
451
+
452
+ quotaContent.innerHTML = '<div class="quota-loading">加载中...</div>';
453
+
454
+ try {
455
+ const url = `/admin/tokens/${encodeURIComponent(refreshToken)}/quotas${forceRefresh ? '?refresh=true' : ''}`;
456
+ const response = await fetch(url, {
457
+ headers: { 'Authorization': `Bearer ${authToken}` }
458
+ });
459
+
460
+ const data = await response.json();
461
+
462
+ if (data.success) {
463
+ const quotaData = data.data;
464
+ const models = quotaData.models;
465
+
466
+ if (Object.keys(models).length === 0) {
467
+ quotaContent.innerHTML = '<div class="quota-empty">暂无额度信息</div>';
468
+ return;
469
+ }
470
+
471
+ const lastUpdated = new Date(quotaData.lastUpdated).toLocaleString('zh-CN', {
472
+ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
473
+ });
474
+
475
+ // 按模型类型分组
476
+ const grouped = { claude: [], gemini: [], other: [] };
477
+ Object.entries(models).forEach(([modelId, quota]) => {
478
+ const item = { modelId, quota };
479
+ if (modelId.toLowerCase().includes('claude')) grouped.claude.push(item);
480
+ else if (modelId.toLowerCase().includes('gemini')) grouped.gemini.push(item);
481
+ else grouped.other.push(item);
482
+ });
483
+
484
+ let html = `<div class="quota-header">更新于 ${lastUpdated}</div>`;
485
+
486
+ // 渲染各组
487
+ if (grouped.claude.length > 0) {
488
+ html += '<div class="quota-group-title">🤖 Claude 模型</div>';
489
+ grouped.claude.forEach(({ modelId, quota }) => {
490
+ const percentage = (quota.remaining * 100).toFixed(1);
491
+ const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
492
+ html += `
493
+ <div class="quota-item">
494
+ <div class="quota-model-name">${modelId}</div>
495
+ <div class="quota-bar-container">
496
+ <div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
497
+ <span class="quota-percentage">${percentage}%</span>
498
+ </div>
499
+ <div class="quota-reset">🔄 重置: ${quota.resetTime}</div>
500
+ </div>
501
+ `;
502
+ });
503
+ }
504
+
505
+ if (grouped.gemini.length > 0) {
506
+ html += '<div class="quota-group-title">💎 Gemini 模型</div>';
507
+ grouped.gemini.forEach(({ modelId, quota }) => {
508
+ const percentage = (quota.remaining * 100).toFixed(1);
509
+ const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
510
+ html += `
511
+ <div class="quota-item">
512
+ <div class="quota-model-name">${modelId}</div>
513
+ <div class="quota-bar-container">
514
+ <div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
515
+ <span class="quota-percentage">${percentage}%</span>
516
+ </div>
517
+ <div class="quota-reset">🔄 重置: ${quota.resetTime}</div>
518
+ </div>
519
+ `;
520
+ });
521
+ }
522
+
523
+ if (grouped.other.length > 0) {
524
+ html += '<div class="quota-group-title">🔧 其他模型</div>';
525
+ grouped.other.forEach(({ modelId, quota }) => {
526
+ const percentage = (quota.remaining * 100).toFixed(1);
527
+ const barColor = percentage > 50 ? '#10b981' : percentage > 20 ? '#f59e0b' : '#ef4444';
528
+ html += `
529
+ <div class="quota-item">
530
+ <div class="quota-model-name">${modelId}</div>
531
+ <div class="quota-bar-container">
532
+ <div class="quota-bar" style="width: ${percentage}%; background: ${barColor};"></div>
533
+ <span class="quota-percentage">${percentage}%</span>
534
+ </div>
535
+ <div class="quota-reset">🔄 重置: ${quota.resetTime}</div>
536
+ </div>
537
+ `;
538
+ });
539
+ }
540
+
541
+ quotaContent.innerHTML = html;
542
+ } else {
543
+ quotaContent.innerHTML = `<div class="quota-error">加载失败: ${data.message}</div>`;
544
+ }
545
+ } catch (error) {
546
+ if (quotaContent) {
547
+ quotaContent.innerHTML = `<div class="quota-error">加载失败: ${error.message}</div>`;
548
+ }
549
+ } finally {
550
+ if (refreshBtn) {
551
+ refreshBtn.disabled = false;
552
+ refreshBtn.textContent = '🔄 立即刷新';
553
+ }
554
+ }
555
+ }
556
+
557
+ async function refreshQuotaData(refreshToken) {
558
+ await loadQuotaData(refreshToken, true);
559
+ }
560
+
561
  async function loadConfig() {
562
  try {
563
  const response = await fetch('/admin/config', {
public/style.css CHANGED
@@ -163,3 +163,24 @@ button.loading::after { content: ''; position: absolute; width: 16px; height: 16
163
  .toast { right: 10px; left: 10px; min-width: auto; max-width: none; }
164
  .modal { padding: 10px; }
165
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  .toast { right: 10px; left: 10px; min-width: auto; max-width: none; }
164
  .modal { padding: 10px; }
165
  }
166
+
167
+ .btn-info { background: var(--info); color: white; }
168
+ .token-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; }
169
+
170
+ .quota-section { margin-top: 1rem; padding-top: 1rem; border-top: 2px solid var(--border); }
171
+ .quota-loading, .quota-error, .quota-empty { text-align: center; padding: 1rem; color: var(--text-light); font-size: 0.875rem; }
172
+ .quota-error { color: var(--danger); }
173
+ .quota-header { font-weight: 600; margin-bottom: 0.75rem; font-size: 0.9rem; color: var(--text); }
174
+ .quota-item { margin-bottom: 1rem; }
175
+ .quota-item:last-child { margin-bottom: 0; }
176
+ .quota-model-name { font-size: 0.8rem; color: var(--text); margin-bottom: 0.5rem; font-family: monospace; font-weight: 500; }
177
+ .quota-bar-container { position: relative; height: 24px; background: var(--border); border-radius: 12px; overflow: hidden; margin-bottom: 0.375rem; }
178
+ .quota-bar { height: 100%; border-radius: 12px; transition: width 0.3s ease; position: relative; }
179
+ .quota-percentage { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 0.75rem; font-weight: 600; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.3); z-index: 1; }
180
+ .quota-reset { font-size: 0.75rem; color: var(--text-light); }
181
+ .quota-group-title { font-size: 1rem; font-weight: 700; color: var(--text); margin: 1.25rem 0 0.75rem 0; padding-bottom: 0.5rem; border-bottom: 2px solid var(--border); }
182
+ .quota-group-title:first-child { margin-top: 0; }
183
+
184
+ @media (max-width: 768px) {
185
+ .token-actions { grid-template-columns: 1fr; }
186
+ }
src/api/client.js CHANGED
@@ -101,6 +101,7 @@ function parseAndEmitStreamChunk(line, state, callback) {
101
 
102
  try {
103
  const data = JSON.parse(line.slice(6));
 
104
  const parts = data.response?.candidates?.[0]?.content?.parts;
105
 
106
  if (parts) {
@@ -126,14 +127,28 @@ function parseAndEmitStreamChunk(line, state, callback) {
126
  }
127
  }
128
 
129
- // 响应结束时发送工具调用
130
- if (data.response?.candidates?.[0]?.finishReason && state.toolCalls.length > 0) {
131
  if (state.thinkingStarted) {
132
  callback({ type: 'thinking', content: '\n</think>\n' });
133
  state.thinkingStarted = false;
134
  }
135
- callback({ type: 'tool_calls', tool_calls: state.toolCalls });
136
- state.toolCalls = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  }
138
  } catch (e) {
139
  // 忽略 JSON 解析错误
@@ -205,6 +220,7 @@ export async function getAvailableModels() {
205
  }
206
  data = await response.json();
207
  }
 
208
  const modelList = Object.keys(data.models).map(id => ({
209
  id,
210
  object: 'model',
@@ -227,6 +243,38 @@ export async function getAvailableModels() {
227
  }
228
  }
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  export async function generateAssistantResponseNoStream(requestBody, token) {
231
 
232
  const headers = buildHeaders(token);
@@ -246,7 +294,7 @@ export async function generateAssistantResponseNoStream(requestBody, token) {
246
  } catch (error) {
247
  await handleApiError(error, token);
248
  }
249
-
250
  // 解析响应内容
251
  const parts = data.response?.candidates?.[0]?.content?.parts || [];
252
  let content = '';
@@ -273,14 +321,22 @@ export async function generateAssistantResponseNoStream(requestBody, token) {
273
  content = `<think>\n${thinkingContent}\n</think>\n${content}`;
274
  }
275
 
 
 
 
 
 
 
 
 
276
  // 生图模型:转换为 markdown 格式
277
  if (imageUrls.length > 0) {
278
  let markdown = content ? content + '\n\n' : '';
279
  markdown += imageUrls.map(url => `![image](${url})`).join('\n\n');
280
- return { content: markdown, toolCalls };
281
  }
282
 
283
- return { content, toolCalls };
284
  }
285
 
286
  export function closeRequester() {
 
101
 
102
  try {
103
  const data = JSON.parse(line.slice(6));
104
+ //console.log(JSON.stringify(data));
105
  const parts = data.response?.candidates?.[0]?.content?.parts;
106
 
107
  if (parts) {
 
127
  }
128
  }
129
 
130
+ // 响应结束时发送工具调用和使用统计
131
+ if (data.response?.candidates?.[0]?.finishReason) {
132
  if (state.thinkingStarted) {
133
  callback({ type: 'thinking', content: '\n</think>\n' });
134
  state.thinkingStarted = false;
135
  }
136
+ if (state.toolCalls.length > 0) {
137
+ callback({ type: 'tool_calls', tool_calls: state.toolCalls });
138
+ state.toolCalls = [];
139
+ }
140
+ // 提取 token 使用统计
141
+ const usage = data.response?.usageMetadata;
142
+ if (usage) {
143
+ callback({
144
+ type: 'usage',
145
+ usage: {
146
+ prompt_tokens: usage.promptTokenCount || 0,
147
+ completion_tokens: usage.candidatesTokenCount || 0,
148
+ total_tokens: usage.totalTokenCount || 0
149
+ }
150
+ });
151
+ }
152
  }
153
  } catch (e) {
154
  // 忽略 JSON 解析错误
 
220
  }
221
  data = await response.json();
222
  }
223
+ //console.log(JSON.stringify(data,null,2));
224
  const modelList = Object.keys(data.models).map(id => ({
225
  id,
226
  object: 'model',
 
243
  }
244
  }
245
 
246
+ export async function getModelsWithQuotas(token) {
247
+ const headers = buildHeaders(token);
248
+
249
+ try {
250
+ let data;
251
+ if (useAxios) {
252
+ data = (await axios(buildAxiosConfig(config.api.modelsUrl, headers, {}))).data;
253
+ } else {
254
+ const response = await requester.antigravity_fetch(config.api.modelsUrl, buildRequesterConfig(headers, {}));
255
+ if (response.status !== 200) {
256
+ const errorBody = await response.text();
257
+ throw { status: response.status, message: errorBody };
258
+ }
259
+ data = await response.json();
260
+ }
261
+
262
+ const quotas = {};
263
+ Object.entries(data.models || {}).forEach(([modelId, modelData]) => {
264
+ if (modelData.quotaInfo) {
265
+ quotas[modelId] = {
266
+ r: modelData.quotaInfo.remainingFraction,
267
+ t: modelData.quotaInfo.resetTime
268
+ };
269
+ }
270
+ });
271
+
272
+ return quotas;
273
+ } catch (error) {
274
+ await handleApiError(error, token);
275
+ }
276
+ }
277
+
278
  export async function generateAssistantResponseNoStream(requestBody, token) {
279
 
280
  const headers = buildHeaders(token);
 
294
  } catch (error) {
295
  await handleApiError(error, token);
296
  }
297
+ //console.log(JSON.stringify(data));
298
  // 解析响应内容
299
  const parts = data.response?.candidates?.[0]?.content?.parts || [];
300
  let content = '';
 
321
  content = `<think>\n${thinkingContent}\n</think>\n${content}`;
322
  }
323
 
324
+ // 提取 token 使用统计
325
+ const usage = data.response?.usageMetadata;
326
+ const usageData = usage ? {
327
+ prompt_tokens: usage.promptTokenCount || 0,
328
+ completion_tokens: usage.candidatesTokenCount || 0,
329
+ total_tokens: usage.totalTokenCount || 0
330
+ } : null;
331
+
332
  // 生图模型:转换为 markdown 格式
333
  if (imageUrls.length > 0) {
334
  let markdown = content ? content + '\n\n' : '';
335
  markdown += imageUrls.map(url => `![image](${url})`).join('\n\n');
336
+ return { content: markdown, toolCalls, usage: usageData };
337
  }
338
 
339
+ return { content, toolCalls, usage: usageData };
340
  }
341
 
342
  export function closeRequester() {
src/auth/quota_manager.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { log } from '../utils/logger.js';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ class QuotaManager {
10
+ constructor(filePath = path.join(__dirname, '..', '..', 'data', 'quotas.json')) {
11
+ this.filePath = filePath;
12
+ this.cache = new Map();
13
+ this.CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
14
+ this.CLEANUP_INTERVAL = 60 * 60 * 1000; // 1小时清理一次
15
+ this.ensureFileExists();
16
+ this.loadFromFile();
17
+ this.startCleanupTimer();
18
+ }
19
+
20
+ ensureFileExists() {
21
+ const dir = path.dirname(this.filePath);
22
+ if (!fs.existsSync(dir)) {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ }
25
+ if (!fs.existsSync(this.filePath)) {
26
+ fs.writeFileSync(this.filePath, JSON.stringify({ meta: { lastCleanup: Date.now(), ttl: this.CLEANUP_INTERVAL }, quotas: {} }, null, 2), 'utf8');
27
+ }
28
+ }
29
+
30
+ loadFromFile() {
31
+ try {
32
+ const data = fs.readFileSync(this.filePath, 'utf8');
33
+ const parsed = JSON.parse(data);
34
+ Object.entries(parsed.quotas || {}).forEach(([key, value]) => {
35
+ this.cache.set(key, value);
36
+ });
37
+ } catch (error) {
38
+ log.error('加载额度文件失败:', error.message);
39
+ }
40
+ }
41
+
42
+ saveToFile() {
43
+ try {
44
+ const quotas = {};
45
+ this.cache.forEach((value, key) => {
46
+ quotas[key] = value;
47
+ });
48
+ const data = {
49
+ meta: { lastCleanup: Date.now(), ttl: this.CLEANUP_INTERVAL },
50
+ quotas
51
+ };
52
+ fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf8');
53
+ } catch (error) {
54
+ log.error('保存额度文件失败:', error.message);
55
+ }
56
+ }
57
+
58
+ updateQuota(refreshToken, quotas) {
59
+ this.cache.set(refreshToken, {
60
+ lastUpdated: Date.now(),
61
+ models: quotas
62
+ });
63
+ this.saveToFile();
64
+ }
65
+
66
+ getQuota(refreshToken) {
67
+ const data = this.cache.get(refreshToken);
68
+ if (!data) return null;
69
+
70
+ // 检查缓存是否过期
71
+ if (Date.now() - data.lastUpdated > this.CACHE_TTL) {
72
+ return null;
73
+ }
74
+
75
+ return data;
76
+ }
77
+
78
+ cleanup() {
79
+ const now = Date.now();
80
+ let cleaned = 0;
81
+
82
+ this.cache.forEach((value, key) => {
83
+ if (now - value.lastUpdated > this.CLEANUP_INTERVAL) {
84
+ this.cache.delete(key);
85
+ cleaned++;
86
+ }
87
+ });
88
+
89
+ if (cleaned > 0) {
90
+ log.info(`清理了 ${cleaned} 个过期的额度记录`);
91
+ this.saveToFile();
92
+ }
93
+ }
94
+
95
+ startCleanupTimer() {
96
+ setInterval(() => this.cleanup(), this.CLEANUP_INTERVAL);
97
+ }
98
+
99
+ convertToBeijingTime(utcTimeStr) {
100
+ if (!utcTimeStr) return 'N/A';
101
+ try {
102
+ const utcDate = new Date(utcTimeStr);
103
+ return utcDate.toLocaleString('zh-CN', {
104
+ month: '2-digit',
105
+ day: '2-digit',
106
+ hour: '2-digit',
107
+ minute: '2-digit',
108
+ timeZone: 'Asia/Shanghai'
109
+ });
110
+ } catch (error) {
111
+ return 'N/A';
112
+ }
113
+ }
114
+ }
115
+
116
+ const quotaManager = new QuotaManager();
117
+ export default quotaManager;
src/auth/token_manager.js CHANGED
@@ -112,25 +112,35 @@ class TokenManager {
112
  token.access_token = response.data.access_token;
113
  token.expires_in = response.data.expires_in;
114
  token.timestamp = Date.now();
115
- this.saveToFile();
116
  return token;
117
  } catch (error) {
118
  throw { statusCode: error.response?.status, message: error.response?.data || error.message };
119
  }
120
  }
121
 
122
- saveToFile() {
123
  try {
124
  const data = fs.readFileSync(this.filePath, 'utf8');
125
  const allTokens = JSON.parse(data);
126
 
127
- this.tokens.forEach(memToken => {
128
- const index = allTokens.findIndex(t => t.refresh_token === memToken.refresh_token);
 
129
  if (index !== -1) {
130
- const { sessionId, ...tokenToSave } = memToken;
131
  allTokens[index] = tokenToSave;
132
  }
133
- });
 
 
 
 
 
 
 
 
 
134
 
135
  fs.writeFileSync(this.filePath, JSON.stringify(allTokens, null, 2), 'utf8');
136
  } catch (error) {
@@ -162,7 +172,7 @@ class TokenManager {
162
  if (!token.projectId) {
163
  if (config.skipProjectIdFetch) {
164
  token.projectId = generateProjectId();
165
- this.saveToFile();
166
  log.info(`...${token.access_token.slice(-8)}: 使用随机生成的projectId: ${token.projectId}`);
167
  } else {
168
  try {
@@ -174,7 +184,7 @@ class TokenManager {
174
  continue;
175
  }
176
  token.projectId = projectId;
177
- this.saveToFile();
178
  } catch (error) {
179
  log.error(`...${token.access_token.slice(-8)}: 获取projectId失败:`, error.message);
180
  this.currentIndex = (this.currentIndex + 1) % this.tokens.length;
@@ -292,6 +302,7 @@ class TokenManager {
292
 
293
  return allTokens.map(token => ({
294
  refresh_token: token.refresh_token,
 
295
  access_token_suffix: token.access_token ? `...${token.access_token.slice(-8)}` : 'N/A',
296
  expires_in: token.expires_in,
297
  timestamp: token.timestamp,
 
112
  token.access_token = response.data.access_token;
113
  token.expires_in = response.data.expires_in;
114
  token.timestamp = Date.now();
115
+ this.saveToFile(token);
116
  return token;
117
  } catch (error) {
118
  throw { statusCode: error.response?.status, message: error.response?.data || error.message };
119
  }
120
  }
121
 
122
+ saveToFile(tokenToUpdate = null) {
123
  try {
124
  const data = fs.readFileSync(this.filePath, 'utf8');
125
  const allTokens = JSON.parse(data);
126
 
127
+ // 如果指定了要更新的token,直接更新它
128
+ if (tokenToUpdate) {
129
+ const index = allTokens.findIndex(t => t.refresh_token === tokenToUpdate.refresh_token);
130
  if (index !== -1) {
131
+ const { sessionId, ...tokenToSave } = tokenToUpdate;
132
  allTokens[index] = tokenToSave;
133
  }
134
+ } else {
135
+ // 否则更新内存中的所有token
136
+ this.tokens.forEach(memToken => {
137
+ const index = allTokens.findIndex(t => t.refresh_token === memToken.refresh_token);
138
+ if (index !== -1) {
139
+ const { sessionId, ...tokenToSave } = memToken;
140
+ allTokens[index] = tokenToSave;
141
+ }
142
+ });
143
+ }
144
 
145
  fs.writeFileSync(this.filePath, JSON.stringify(allTokens, null, 2), 'utf8');
146
  } catch (error) {
 
172
  if (!token.projectId) {
173
  if (config.skipProjectIdFetch) {
174
  token.projectId = generateProjectId();
175
+ this.saveToFile(token);
176
  log.info(`...${token.access_token.slice(-8)}: 使用随机生成的projectId: ${token.projectId}`);
177
  } else {
178
  try {
 
184
  continue;
185
  }
186
  token.projectId = projectId;
187
+ this.saveToFile(token);
188
  } catch (error) {
189
  log.error(`...${token.access_token.slice(-8)}: 获取projectId失败:`, error.message);
190
  this.currentIndex = (this.currentIndex + 1) % this.tokens.length;
 
302
 
303
  return allTokens.map(token => ({
304
  refresh_token: token.refresh_token,
305
+ access_token: token.access_token,
306
  access_token_suffix: token.access_token ? `...${token.access_token.slice(-8)}` : 'N/A',
307
  expires_in: token.expires_in,
308
  timestamp: token.timestamp,
src/routes/admin.js CHANGED
@@ -1,6 +1,7 @@
1
  import express from 'express';
2
  import { generateToken, authMiddleware } from '../auth/jwt.js';
3
  import tokenManager from '../auth/token_manager.js';
 
4
  import config, { getConfigJson, saveConfigJson } from '../config/config.js';
5
  import logger from '../utils/logger.js';
6
  import { generateProjectId } from '../utils/idGenerator.js';
@@ -8,6 +9,7 @@ import { parseEnvFile, updateEnvFile } from '../utils/envParser.js';
8
  import { reloadConfig } from '../utils/configReloader.js';
9
  import { OAUTH_CONFIG } from '../constants/oauth.js';
10
  import { deepMerge } from '../utils/deepMerge.js';
 
11
  import path from 'path';
12
  import { fileURLToPath } from 'url';
13
  import dotenv from 'dotenv';
@@ -170,4 +172,60 @@ router.put('/config', authMiddleware, (req, res) => {
170
  }
171
  });
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  export default router;
 
1
  import express from 'express';
2
  import { generateToken, authMiddleware } from '../auth/jwt.js';
3
  import tokenManager from '../auth/token_manager.js';
4
+ import quotaManager from '../auth/quota_manager.js';
5
  import config, { getConfigJson, saveConfigJson } from '../config/config.js';
6
  import logger from '../utils/logger.js';
7
  import { generateProjectId } from '../utils/idGenerator.js';
 
9
  import { reloadConfig } from '../utils/configReloader.js';
10
  import { OAUTH_CONFIG } from '../constants/oauth.js';
11
  import { deepMerge } from '../utils/deepMerge.js';
12
+ import { getModelsWithQuotas } from '../api/client.js';
13
  import path from 'path';
14
  import { fileURLToPath } from 'url';
15
  import dotenv from 'dotenv';
 
172
  }
173
  });
174
 
175
+ // 获取指定Token的模型额度
176
+ router.get('/tokens/:refreshToken/quotas', authMiddleware, async (req, res) => {
177
+ try {
178
+ const { refreshToken } = req.params;
179
+ const forceRefresh = req.query.refresh === 'true';
180
+ const tokens = tokenManager.getTokenList();
181
+ let tokenData = tokens.find(t => t.refresh_token === refreshToken);
182
+
183
+ if (!tokenData) {
184
+ return res.status(404).json({ success: false, message: 'Token不存在' });
185
+ }
186
+
187
+ // 检查token是否过期,如果过期则刷新
188
+ if (tokenManager.isExpired(tokenData)) {
189
+ try {
190
+ tokenData = await tokenManager.refreshToken(tokenData);
191
+ } catch (error) {
192
+ logger.error('刷新token失败:', error.message);
193
+ return res.status(401).json({ success: false, message: 'Token已过期且刷新失败' });
194
+ }
195
+ }
196
+
197
+ // 先从缓存获取(除非强制刷新)
198
+ let quotaData = forceRefresh ? null : quotaManager.getQuota(refreshToken);
199
+
200
+ if (!quotaData) {
201
+ // 缓存未命中或强制刷新,从API获取
202
+ const token = { access_token: tokenData.access_token, refresh_token: refreshToken };
203
+ const quotas = await getModelsWithQuotas(token);
204
+ quotaManager.updateQuota(refreshToken, quotas);
205
+ quotaData = { lastUpdated: Date.now(), models: quotas };
206
+ }
207
+
208
+ // 转换时间为北京时间
209
+ const modelsWithBeijingTime = {};
210
+ Object.entries(quotaData.models).forEach(([modelId, quota]) => {
211
+ modelsWithBeijingTime[modelId] = {
212
+ remaining: quota.r,
213
+ resetTime: quotaManager.convertToBeijingTime(quota.t),
214
+ resetTimeRaw: quota.t
215
+ };
216
+ });
217
+
218
+ res.json({
219
+ success: true,
220
+ data: {
221
+ lastUpdated: quotaData.lastUpdated,
222
+ models: modelsWithBeijingTime
223
+ }
224
+ });
225
+ } catch (error) {
226
+ logger.error('获取额度失败:', error.message);
227
+ res.status(500).json({ success: false, message: error.message });
228
+ }
229
+ });
230
+
231
  export default router;
src/server/index.js CHANGED
@@ -41,8 +41,7 @@ const writeStreamData = (res, data) => {
41
  };
42
 
43
  // 工具函数:结束流式响应
44
- const endStream = (res, id, created, model, finish_reason) => {
45
- writeStreamData(res, createStreamChunk(id, created, model, {}, finish_reason));
46
  res.write('data: [DONE]\n\n');
47
  res.end();
48
  };
@@ -134,22 +133,29 @@ app.post('/v1/chat/completions', async (req, res) => {
134
  setStreamHeaders(res);
135
 
136
  if (isImageModel) {
137
- const { content } = await generateAssistantResponseNoStream(requestBody, token);
138
  writeStreamData(res, createStreamChunk(id, created, model, { content }));
139
- endStream(res, id, created, model, 'stop');
 
140
  } else {
141
  let hasToolCall = false;
 
142
  await generateAssistantResponse(requestBody, token, (data) => {
143
- const delta = data.type === 'tool_calls'
144
- ? { tool_calls: data.tool_calls }
145
- : { content: data.content };
146
- if (data.type === 'tool_calls') hasToolCall = true;
147
- writeStreamData(res, createStreamChunk(id, created, model, delta));
 
 
 
 
148
  });
149
- endStream(res, id, created, model, hasToolCall ? 'tool_calls' : 'stop');
 
150
  }
151
  } else {
152
- const { content, toolCalls } = await generateAssistantResponseNoStream(requestBody, token);
153
  const message = { role: 'assistant', content };
154
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
155
 
@@ -162,7 +168,8 @@ app.post('/v1/chat/completions', async (req, res) => {
162
  index: 0,
163
  message,
164
  finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
165
- }]
 
166
  });
167
  }
168
  } catch (error) {
@@ -174,7 +181,8 @@ app.post('/v1/chat/completions', async (req, res) => {
174
  if (stream) {
175
  setStreamHeaders(res);
176
  writeStreamData(res, createStreamChunk(id, created, model, { content: errorContent }));
177
- endStream(res, id, created, model, 'stop');
 
178
  } else {
179
  res.json({
180
  id,
 
41
  };
42
 
43
  // 工具函数:结束流式响应
44
+ const endStream = (res) => {
 
45
  res.write('data: [DONE]\n\n');
46
  res.end();
47
  };
 
133
  setStreamHeaders(res);
134
 
135
  if (isImageModel) {
136
+ const { content, usage } = await generateAssistantResponseNoStream(requestBody, token);
137
  writeStreamData(res, createStreamChunk(id, created, model, { content }));
138
+ writeStreamData(res, { ...createStreamChunk(id, created, model, {}, 'stop'), usage });
139
+ endStream(res);
140
  } else {
141
  let hasToolCall = false;
142
+ let usageData = null;
143
  await generateAssistantResponse(requestBody, token, (data) => {
144
+ if (data.type === 'usage') {
145
+ usageData = data.usage;
146
+ } else {
147
+ const delta = data.type === 'tool_calls'
148
+ ? { tool_calls: data.tool_calls }
149
+ : { content: data.content };
150
+ if (data.type === 'tool_calls') hasToolCall = true;
151
+ writeStreamData(res, createStreamChunk(id, created, model, delta));
152
+ }
153
  });
154
+ writeStreamData(res, { ...createStreamChunk(id, created, model, {}, hasToolCall ? 'tool_calls' : 'stop'), usage: usageData });
155
+ endStream(res);
156
  }
157
  } else {
158
+ const { content, toolCalls, usage } = await generateAssistantResponseNoStream(requestBody, token);
159
  const message = { role: 'assistant', content };
160
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
161
 
 
168
  index: 0,
169
  message,
170
  finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop'
171
+ }],
172
+ usage
173
  });
174
  }
175
  } catch (error) {
 
181
  if (stream) {
182
  setStreamHeaders(res);
183
  writeStreamData(res, createStreamChunk(id, created, model, { content: errorContent }));
184
+ writeStreamData(res, createStreamChunk(id, created, model, {}, 'stop'));
185
+ endStream(res);
186
  } else {
187
  res.json({
188
  id,
src/utils/utils.js CHANGED
@@ -69,7 +69,7 @@ function handleAssistantMessage(message, antigravityMessages){
69
  lastMessage.parts.push(...antigravityTools)
70
  }else{
71
  const parts = [];
72
- if (hasContent) parts.push({ text: message.content });
73
  parts.push(...antigravityTools);
74
 
75
  antigravityMessages.push({
 
69
  lastMessage.parts.push(...antigravityTools)
70
  }else{
71
  const parts = [];
72
+ if (hasContent) parts.push({ text: message.content.trimEnd() });
73
  parts.push(...antigravityTools);
74
 
75
  antigravityMessages.push({