coding-tool-x 3.2.0

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 (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,610 @@
1
+ const express = require('express');
2
+ const httpProxy = require('http-proxy');
3
+ const http = require('http');
4
+ const chalk = require('chalk');
5
+ const { broadcastLog, broadcastSchedulerState } = require('./websocket-server');
6
+ const { allocateChannel, releaseChannel, getSchedulerState } = require('./services/channel-scheduler');
7
+ const { recordSuccess, recordFailure } = require('./services/channel-health');
8
+ const { loadConfig } = require('../config/loader');
9
+ const DEFAULT_CONFIG = require('../config/default');
10
+ const { resolveModelPricing } = require('./utils/pricing');
11
+ const { recordRequest: recordGeminiRequest } = require('./services/gemini-statistics-service');
12
+ const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
13
+ const { createDecodedStream } = require('./services/response-decoder');
14
+ const { getEffectiveApiKey } = require('./services/gemini-channels');
15
+ const { persistProxyRequestSnapshot } = require('./services/request-logger');
16
+
17
+ let proxyServer = null;
18
+ let proxyApp = null;
19
+ let currentPort = null;
20
+
21
+ // 用于存储每个请求的元数据
22
+ const requestMetadata = new Map();
23
+
24
+ // 用于缓存已打印过的模型重定向规则,避免重复打印
25
+ // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
26
+ const printedGeminiRedirectCache = new Map();
27
+
28
+ // Gemini 模型定价(每百万 tokens 的价格,单位:美元)
29
+ // 作为 model-metadata 未覆盖时的兜底值
30
+ const PRICING = {
31
+ 'gemini-2.5-pro': { input: 1.25, output: 5 },
32
+ 'gemini-2.5-flash': { input: 0.075, output: 0.3 },
33
+ 'gemini-2.0-flash-exp': { input: 0, output: 0 }, // 实验性免费
34
+ 'gemini-2.0-flash-thinking-exp-1219': { input: 0, output: 0 }, // 实验性免费
35
+ 'gemini-1.5-pro': { input: 1.25, output: 5 },
36
+ 'gemini-1.5-flash': { input: 0.075, output: 0.3 },
37
+ 'gemini-1.5-flash-8b': { input: 0.0375, output: 0.15 },
38
+ 'gemini-1.0-pro': { input: 0.5, output: 1.5 },
39
+ // 旧版本别名
40
+ 'gemini-pro': { input: 0.5, output: 1.5 },
41
+ 'gemini-pro-vision': { input: 0.5, output: 1.5 }
42
+ };
43
+
44
+ const GEMINI_BASE_PRICING = DEFAULT_CONFIG.pricing.gemini;
45
+ const ONE_MILLION = 1000000;
46
+
47
+ function resolveGeminiTarget(baseUrl = '', requestPath = '') {
48
+ let target = baseUrl || '';
49
+ if (target.endsWith('/')) {
50
+ target = target.slice(0, -1);
51
+ }
52
+ if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
53
+ target = target.slice(0, -3);
54
+ }
55
+ return target;
56
+ }
57
+
58
+ /**
59
+ * 应用模型重定向(精确匹配)
60
+ * @param {string} originalModel - 原始模型名称
61
+ * @param {object} channel - 渠道对象,包含 modelRedirects 数组
62
+ * @returns {string} 重定向后的模型名称
63
+ */
64
+ function redirectModel(originalModel, channel) {
65
+ if (!originalModel) return originalModel;
66
+
67
+ const modelRedirects = channel?.modelRedirects;
68
+ if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
69
+ for (const rule of modelRedirects) {
70
+ if (rule.from && rule.to && rule.from === originalModel) {
71
+ return rule.to;
72
+ }
73
+ }
74
+ }
75
+
76
+ return originalModel;
77
+ }
78
+
79
+ /**
80
+ * 计算请求成本
81
+ */
82
+ function calculateCost(model, tokens) {
83
+ // 尝试精确匹配
84
+ let fallbackPricing = PRICING[model];
85
+
86
+ // 如果没有精确匹配,尝试模糊匹配
87
+ if (!fallbackPricing) {
88
+ const modelLower = String(model || '').toLowerCase();
89
+ if (modelLower.includes('gemini-2.5-pro')) {
90
+ fallbackPricing = PRICING['gemini-2.5-pro'];
91
+ } else if (modelLower.includes('gemini-2.5-flash')) {
92
+ fallbackPricing = PRICING['gemini-2.5-flash'];
93
+ } else if (modelLower.includes('gemini-2.0-flash-thinking')) {
94
+ fallbackPricing = PRICING['gemini-2.0-flash-thinking-exp-1219'];
95
+ } else if (modelLower.includes('gemini-2.0-flash')) {
96
+ fallbackPricing = PRICING['gemini-2.0-flash-exp'];
97
+ } else if (modelLower.includes('gemini-1.5-pro')) {
98
+ fallbackPricing = PRICING['gemini-1.5-pro'];
99
+ } else if (modelLower.includes('gemini-1.5-flash-8b')) {
100
+ fallbackPricing = PRICING['gemini-1.5-flash-8b'];
101
+ } else if (modelLower.includes('gemini-1.5-flash')) {
102
+ fallbackPricing = PRICING['gemini-1.5-flash'];
103
+ } else if (modelLower.includes('gemini-1.0-pro')) {
104
+ fallbackPricing = PRICING['gemini-1.0-pro'];
105
+ } else if (modelLower.includes('gemini-pro')) {
106
+ fallbackPricing = PRICING['gemini-pro'];
107
+ }
108
+ }
109
+
110
+ const pricing = resolveModelPricing('gemini', model, fallbackPricing, GEMINI_BASE_PRICING);
111
+ const inputRate = typeof pricing.input === 'number' ? pricing.input : GEMINI_BASE_PRICING.input;
112
+ const outputRate = typeof pricing.output === 'number' ? pricing.output : GEMINI_BASE_PRICING.output;
113
+
114
+ return (
115
+ (tokens.input || 0) * inputRate / ONE_MILLION +
116
+ (tokens.output || 0) * outputRate / ONE_MILLION
117
+ );
118
+ }
119
+
120
+ // 启动 Gemini 代理服务器
121
+ async function startGeminiProxyServer(options = {}) {
122
+ // options.preserveStartTime - 是否保留现有的启动时间(用于切换渠道时)
123
+ const preserveStartTime = options.preserveStartTime || false;
124
+
125
+ if (proxyServer) {
126
+ console.log('Gemini proxy server already running on port', currentPort);
127
+ return { success: true, port: currentPort };
128
+ }
129
+
130
+ try {
131
+ const config = loadConfig();
132
+ const port = config.ports?.geminiProxy || 20090;
133
+ currentPort = port;
134
+
135
+ proxyApp = express();
136
+ const proxy = httpProxy.createProxyServer({});
137
+
138
+ proxy.on('proxyReq', (proxyReq, req) => {
139
+ const activeChannel = req.selectedChannel;
140
+ if (!activeChannel) return;
141
+
142
+ const requestId = `gemini-${Date.now()}-${Math.random()}`;
143
+ let modelFromUrl = '';
144
+ const urlMatch = req.url.match(/\/models\/([\w.-]+):/);
145
+ if (urlMatch) {
146
+ modelFromUrl = urlMatch[1];
147
+ }
148
+
149
+ requestMetadata.set(req, {
150
+ id: requestId,
151
+ channel: activeChannel.name,
152
+ channelId: activeChannel.id,
153
+ startTime: Date.now(),
154
+ modelFromUrl
155
+ });
156
+
157
+ proxyReq.removeHeader('authorization');
158
+ proxyReq.removeHeader('x-goog-api-key');
159
+ const effectiveKey = req.effectiveApiKey;
160
+ proxyReq.setHeader('authorization', `Bearer ${effectiveKey}`);
161
+ if (!proxyReq.getHeader('content-type')) {
162
+ proxyReq.setHeader('content-type', 'application/json');
163
+ }
164
+ });
165
+
166
+ proxyApp.use(async (req, res) => {
167
+ try {
168
+ const channel = await allocateChannel({ source: 'gemini', enableSessionBinding: false });
169
+ req.selectedChannel = channel;
170
+
171
+ const release = (() => {
172
+ let released = false;
173
+ return () => {
174
+ if (released) return;
175
+ released = true;
176
+ releaseChannel(channel.id, 'gemini');
177
+ broadcastSchedulerState('gemini', getSchedulerState('gemini'));
178
+ };
179
+ })();
180
+
181
+ res.on('close', release);
182
+ res.on('error', release);
183
+
184
+ broadcastSchedulerState('gemini', getSchedulerState('gemini'));
185
+
186
+ const effectiveKey = getEffectiveApiKey(channel);
187
+ if (!effectiveKey) {
188
+ release();
189
+ return res.status(401).json({
190
+ error: {
191
+ message: 'API key not configured or expired. Please update your channel key.',
192
+ type: 'authentication_error'
193
+ }
194
+ });
195
+ }
196
+ req.effectiveApiKey = effectiveKey;
197
+
198
+ // 记录请求快照到文件(由 CC_TOOL_LOG_REQUESTS 环境变量控制)
199
+ persistProxyRequestSnapshot('gemini', {
200
+ timestamp: Date.now(),
201
+ source: 'gemini',
202
+ channel: channel.name,
203
+ request: {
204
+ method: req.method,
205
+ url: req.url,
206
+ path: req.path,
207
+ headers: req.headers,
208
+ body: req.body || null
209
+ }
210
+ });
211
+
212
+ // 从 URL 中提取模型名称并应用重定向
213
+ // URL 格式: /models/gemini-2.5-pro:generateContent 或 /v1/models/gemini-2.5-pro:generateContent
214
+ const urlMatch = req.url.match(/\/models\/([\w.-]+)(:[^?]*)?/);
215
+ if (urlMatch) {
216
+ const originalModel = urlMatch[1];
217
+ const redirectedModel = redirectModel(originalModel, channel);
218
+
219
+ if (redirectedModel !== originalModel) {
220
+ // 替换 URL 中的模型名称
221
+ req.url = req.url.replace(`/models/${originalModel}`, `/models/${redirectedModel}`);
222
+
223
+ // 只在重定向规则变化时打印日志(避免每次请求都打印)
224
+ const cachedRedirects = printedGeminiRedirectCache.get(channel.id) || {};
225
+ if (cachedRedirects[originalModel] !== redirectedModel) {
226
+ cachedRedirects[originalModel] = redirectedModel;
227
+ printedGeminiRedirectCache.set(channel.id, cachedRedirects);
228
+ console.log(`[Gemini Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
229
+ }
230
+ }
231
+ }
232
+
233
+ const target = resolveGeminiTarget(channel.baseUrl, req.url);
234
+
235
+ proxy.web(req, res, {
236
+ target,
237
+ changeOrigin: true,
238
+ proxyTimeout: 120000, // 代理连接超时 2 分钟
239
+ timeout: 120000 // 请求超时 2 分钟
240
+ }, (err) => {
241
+ release();
242
+ if (err) {
243
+ recordFailure(channel.id, 'gemini', err);
244
+ console.error('Gemini proxy error:', err);
245
+ if (res && !res.headersSent) {
246
+ res.status(502).json({
247
+ error: {
248
+ message: 'Proxy error: ' + err.message,
249
+ type: 'proxy_error'
250
+ }
251
+ });
252
+ }
253
+ }
254
+ });
255
+ } catch (error) {
256
+ console.error('Gemini channel allocation error:', error);
257
+ if (!res.headersSent) {
258
+ res.status(503).json({
259
+ error: {
260
+ message: error.message || 'No Gemini channel available',
261
+ type: 'channel_pool_exhausted'
262
+ }
263
+ });
264
+ }
265
+ }
266
+ });
267
+
268
+ // 监听代理响应 (OpenAI 兼容格式)
269
+ proxy.on('proxyRes', (proxyRes, req, res) => {
270
+ const metadata = requestMetadata.get(req);
271
+ if (!metadata) {
272
+ return;
273
+ }
274
+
275
+ // 检查响应是否已关闭
276
+ if (res.writableEnded || res.destroyed) {
277
+ requestMetadata.delete(req);
278
+ return;
279
+ }
280
+
281
+ // 标记响应是否已关闭
282
+ let isResponseClosed = false;
283
+
284
+ // 监听响应关闭事件
285
+ res.on('close', () => {
286
+ isResponseClosed = true;
287
+ requestMetadata.delete(req);
288
+ });
289
+
290
+ // 监听响应错误事件
291
+ res.on('error', (err) => {
292
+ isResponseClosed = true;
293
+ // 忽略客户端断开连接的常见错误
294
+ if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
295
+ console.error('Response error:', err);
296
+ }
297
+ requestMetadata.delete(req);
298
+ });
299
+
300
+ let buffer = '';
301
+ let tokenData = {
302
+ inputTokens: 0,
303
+ outputTokens: 0,
304
+ cachedTokens: 0,
305
+ reasoningTokens: 0,
306
+ totalTokens: 0,
307
+ model: ''
308
+ };
309
+ const parsedStream = createDecodedStream(proxyRes);
310
+
311
+ parsedStream.on('data', (chunk) => {
312
+ // 如果响应已关闭,停止处理
313
+ if (isResponseClosed) {
314
+ return;
315
+ }
316
+
317
+ buffer += chunk.toString('utf8');
318
+
319
+ // 检查是否是 SSE 流
320
+ if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
321
+ // 处理 SSE 事件
322
+ const events = buffer.split('\n\n');
323
+ buffer = events.pop() || '';
324
+
325
+ events.forEach((eventText, index) => {
326
+ if (!eventText.trim()) return;
327
+
328
+ try {
329
+ const lines = eventText.split('\n');
330
+ let data = '';
331
+
332
+ lines.forEach(line => {
333
+ if (line.startsWith('data:')) {
334
+ data = line.substring(5).trim();
335
+ }
336
+ });
337
+
338
+ if (!data) return;
339
+
340
+ if (data === '[DONE]') return;
341
+
342
+ const parsed = JSON.parse(data);
343
+
344
+ // 提取模型信息
345
+ if (parsed.model && !tokenData.model) {
346
+ tokenData.model = parsed.model;
347
+ }
348
+
349
+ // 提取 usage 信息 (支持 OpenAI 和 Gemini 原生格式)
350
+ if (tokenData.inputTokens === 0) {
351
+ // OpenAI 格式
352
+ if (parsed.usage) {
353
+ tokenData.inputTokens = parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0;
354
+ tokenData.outputTokens = parsed.usage.completion_tokens || parsed.usage.output_tokens || 0;
355
+ tokenData.totalTokens = parsed.usage.total_tokens || 0;
356
+
357
+ // Gemini 可能包含缓存信息
358
+ if (parsed.usage.prompt_tokens_details) {
359
+ tokenData.cachedTokens = parsed.usage.prompt_tokens_details.cached_tokens || 0;
360
+ }
361
+ }
362
+ // Gemini 原生格式
363
+ else if (parsed.usageMetadata) {
364
+ tokenData.inputTokens = parsed.usageMetadata.promptTokenCount || 0;
365
+ tokenData.outputTokens = parsed.usageMetadata.candidatesTokenCount || 0;
366
+ tokenData.totalTokens = parsed.usageMetadata.totalTokenCount || 0;
367
+
368
+ // Gemini 缓存信息
369
+ if (parsed.usageMetadata.cachedContentTokenCount) {
370
+ tokenData.cachedTokens = parsed.usageMetadata.cachedContentTokenCount;
371
+ }
372
+ }
373
+ }
374
+ } catch (err) {
375
+ // 忽略解析错误
376
+ }
377
+ });
378
+ }
379
+ });
380
+
381
+ parsedStream.on('end', () => {
382
+ // 如果不是流式响应,尝试从完整响应中解析
383
+ if (!proxyRes.headers['content-type']?.includes('text/event-stream')) {
384
+ try {
385
+ const parsed = JSON.parse(buffer);
386
+ if (parsed.model) {
387
+ tokenData.model = parsed.model;
388
+ }
389
+
390
+ // OpenAI 格式
391
+ if (parsed.usage) {
392
+ tokenData.inputTokens = parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0;
393
+ tokenData.outputTokens = parsed.usage.completion_tokens || parsed.usage.output_tokens || 0;
394
+ tokenData.totalTokens = parsed.usage.total_tokens || 0;
395
+
396
+ if (parsed.usage.prompt_tokens_details) {
397
+ tokenData.cachedTokens = parsed.usage.prompt_tokens_details.cached_tokens || 0;
398
+ }
399
+ }
400
+ // Gemini 原生格式
401
+ else if (parsed.usageMetadata) {
402
+ tokenData.inputTokens = parsed.usageMetadata.promptTokenCount || 0;
403
+ tokenData.outputTokens = parsed.usageMetadata.candidatesTokenCount || 0;
404
+ tokenData.totalTokens = parsed.usageMetadata.totalTokenCount || 0;
405
+
406
+ if (parsed.usageMetadata.cachedContentTokenCount) {
407
+ tokenData.cachedTokens = parsed.usageMetadata.cachedContentTokenCount;
408
+ }
409
+ }
410
+ } catch (err) {
411
+ // 忽略解析错误
412
+ }
413
+ }
414
+
415
+ // 如果没有从响应中提取到模型,使用 URL 中的模型
416
+ if (!tokenData.model && metadata.modelFromUrl) {
417
+ tokenData.model = metadata.modelFromUrl;
418
+ }
419
+
420
+ // 记录日志和统计
421
+ const now = new Date();
422
+ const time = now.toLocaleTimeString('zh-CN', {
423
+ hour12: false,
424
+ hour: '2-digit',
425
+ minute: '2-digit',
426
+ second: '2-digit'
427
+ });
428
+
429
+ // 记录统计数据(先计算)
430
+ const tokens = {
431
+ input: tokenData.inputTokens,
432
+ output: tokenData.outputTokens,
433
+ total: tokenData.totalTokens || (tokenData.inputTokens + tokenData.outputTokens)
434
+ };
435
+ const cost = calculateCost(tokenData.model, tokens);
436
+
437
+ // 只有在有 token 数据时才广播日志和记录统计
438
+ if (tokenData.inputTokens > 0 || tokenData.outputTokens > 0 || tokenData.totalTokens > 0) {
439
+ // 广播日志(仅当响应仍然开放时)
440
+ if (!isResponseClosed) {
441
+ const logData = {
442
+ type: 'log',
443
+ id: metadata.id,
444
+ time: time,
445
+ channel: metadata.channel,
446
+ model: tokenData.model,
447
+ inputTokens: tokenData.inputTokens,
448
+ outputTokens: tokenData.outputTokens,
449
+ cachedTokens: tokenData.cachedTokens,
450
+ reasoningTokens: tokenData.reasoningTokens,
451
+ totalTokens: tokenData.totalTokens || (tokenData.inputTokens + tokenData.outputTokens),
452
+ cost: cost,
453
+ source: 'gemini'
454
+ };
455
+
456
+ broadcastLog(logData);
457
+ }
458
+
459
+ // 记录统计
460
+ const duration = Date.now() - metadata.startTime;
461
+
462
+ recordGeminiRequest({
463
+ id: metadata.id,
464
+ timestamp: new Date(metadata.startTime).toISOString(),
465
+ toolType: 'gemini',
466
+ channel: metadata.channel,
467
+ channelId: metadata.channelId,
468
+ model: tokenData.model,
469
+ tokens: {
470
+ input: tokenData.inputTokens,
471
+ output: tokenData.outputTokens,
472
+ cached: tokenData.cachedTokens,
473
+ total: tokens.total
474
+ },
475
+ duration: duration,
476
+ success: true,
477
+ cost: cost
478
+ });
479
+
480
+ recordSuccess(metadata.channelId, 'gemini');
481
+ }
482
+
483
+ if (!isResponseClosed) {
484
+ requestMetadata.delete(req);
485
+ }
486
+ });
487
+
488
+ parsedStream.on('error', (err) => {
489
+ // 忽略代理响应错误(可能是网络问题)
490
+ if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
491
+ console.error('Proxy response error:', err);
492
+ }
493
+ isResponseClosed = true;
494
+ recordFailure(metadata.channelId, 'gemini', err);
495
+ requestMetadata.delete(req);
496
+ });
497
+ });
498
+
499
+ // 处理代理错误
500
+ proxy.on('error', (err, req, res) => {
501
+ console.error('Gemini proxy error:', err);
502
+ if (req && req.selectedChannel) {
503
+ recordFailure(req.selectedChannel.id, 'gemini', err);
504
+ releaseChannel(req.selectedChannel.id, 'gemini');
505
+ broadcastSchedulerState('gemini', getSchedulerState('gemini'));
506
+ }
507
+ if (res && !res.headersSent) {
508
+ res.status(502).json({
509
+ error: {
510
+ message: 'Proxy error: ' + err.message,
511
+ type: 'proxy_error'
512
+ }
513
+ });
514
+ }
515
+ });
516
+
517
+ // 启动服务器
518
+ proxyServer = http.createServer(proxyApp);
519
+
520
+ return new Promise((resolve, reject) => {
521
+ proxyServer.listen(port, '127.0.0.1', () => {
522
+ console.log(`Gemini proxy server started on http://127.0.0.1:${port}`);
523
+
524
+ // 保存代理启动时间(如果是切换渠道,保留原有启动时间)
525
+ saveProxyStartTime('gemini', preserveStartTime);
526
+
527
+ resolve({ success: true, port });
528
+ });
529
+
530
+ proxyServer.on('error', (err) => {
531
+ if (err.code === 'EADDRINUSE') {
532
+ console.error(chalk.red(`\nGemini proxy port ${port} is already in use`));
533
+ } else {
534
+ console.error('Failed to start Gemini proxy server:', err);
535
+ }
536
+ proxyServer = null;
537
+ proxyApp = null;
538
+ currentPort = null;
539
+ reject(err);
540
+ });
541
+ });
542
+ } catch (err) {
543
+ console.error('Error starting Gemini proxy server:', err);
544
+ throw err;
545
+ }
546
+ }
547
+
548
+ // 停止 Gemini 代理服务器
549
+ async function stopGeminiProxyServer(options = {}) {
550
+ // options.clearStartTime - 是否清除启动时间(默认 true)
551
+ const clearStartTime = options.clearStartTime !== false;
552
+
553
+ if (!proxyServer) {
554
+ return { success: true, message: 'Gemini proxy server not running' };
555
+ }
556
+
557
+ requestMetadata.clear();
558
+
559
+ return new Promise((resolve) => {
560
+ proxyServer.close(() => {
561
+ console.log('Gemini proxy server stopped');
562
+
563
+ // 清除代理启动时间(仅当明确要求时)
564
+ if (clearStartTime) {
565
+ clearProxyStartTime('gemini');
566
+ }
567
+
568
+ proxyServer = null;
569
+ proxyApp = null;
570
+ const stoppedPort = currentPort;
571
+ currentPort = null;
572
+ resolve({ success: true, port: stoppedPort });
573
+ });
574
+ });
575
+ }
576
+
577
+ // 获取代理服务器状态
578
+ function getGeminiProxyStatus() {
579
+ const config = loadConfig();
580
+ const startTime = getProxyStartTime('gemini');
581
+ const runtime = getProxyRuntime('gemini');
582
+
583
+ return {
584
+ running: !!proxyServer,
585
+ port: currentPort,
586
+ defaultPort: config.ports?.geminiProxy || 20090,
587
+ startTime,
588
+ runtime
589
+ };
590
+ }
591
+
592
+ /**
593
+ * 清除指定渠道的模型重定向日志缓存
594
+ * 用于在渠道配置更新后触发重新打印日志
595
+ * @param {string} channelId - 渠道 ID
596
+ */
597
+ function clearGeminiRedirectCache(channelId) {
598
+ if (channelId) {
599
+ printedGeminiRedirectCache.delete(channelId);
600
+ } else {
601
+ printedGeminiRedirectCache.clear();
602
+ }
603
+ }
604
+
605
+ module.exports = {
606
+ startGeminiProxyServer,
607
+ stopGeminiProxyServer,
608
+ getGeminiProxyStatus,
609
+ clearGeminiRedirectCache
610
+ };