@adversity/coding-tool-x 2.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.
- package/CHANGELOG.md +333 -0
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/bin/ctx.js +8 -0
- package/dist/web/assets/index-D1AYlFLZ.js +3220 -0
- package/dist/web/assets/index-aL3cKxSK.css +41 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/index.html +14 -0
- package/dist/web/logo.png +0 -0
- package/docs/CHANGELOG.md +582 -0
- package/docs/DIRECTORY_MIGRATION.md +112 -0
- package/docs/PROJECT_STRUCTURE.md +396 -0
- package/docs/bannel.png +0 -0
- package/docs/home.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/multi-channel-load-balancing.md +249 -0
- package/package.json +73 -0
- package/src/commands/channels.js +504 -0
- package/src/commands/cli-type.js +99 -0
- package/src/commands/daemon.js +286 -0
- package/src/commands/doctor.js +332 -0
- package/src/commands/list.js +222 -0
- package/src/commands/logs.js +259 -0
- package/src/commands/port-config.js +115 -0
- package/src/commands/proxy-control.js +258 -0
- package/src/commands/proxy.js +152 -0
- package/src/commands/resume.js +137 -0
- package/src/commands/search.js +190 -0
- package/src/commands/stats.js +224 -0
- package/src/commands/switch.js +48 -0
- package/src/commands/toggle-proxy.js +222 -0
- package/src/commands/ui.js +92 -0
- package/src/commands/workspace.js +454 -0
- package/src/config/default.js +40 -0
- package/src/config/loader.js +75 -0
- package/src/config/paths.js +121 -0
- package/src/index.js +373 -0
- package/src/reset-config.js +92 -0
- package/src/server/api/agents.js +248 -0
- package/src/server/api/aliases.js +36 -0
- package/src/server/api/channels.js +258 -0
- package/src/server/api/claude-hooks.js +480 -0
- package/src/server/api/codex-channels.js +312 -0
- package/src/server/api/codex-projects.js +91 -0
- package/src/server/api/codex-proxy.js +182 -0
- package/src/server/api/codex-sessions.js +491 -0
- package/src/server/api/codex-statistics.js +57 -0
- package/src/server/api/commands.js +245 -0
- package/src/server/api/config-templates.js +182 -0
- package/src/server/api/config.js +147 -0
- package/src/server/api/convert.js +127 -0
- package/src/server/api/dashboard.js +125 -0
- package/src/server/api/env.js +144 -0
- package/src/server/api/favorites.js +77 -0
- package/src/server/api/gemini-channels.js +261 -0
- package/src/server/api/gemini-projects.js +91 -0
- package/src/server/api/gemini-proxy.js +160 -0
- package/src/server/api/gemini-sessions.js +397 -0
- package/src/server/api/gemini-statistics.js +57 -0
- package/src/server/api/health-check.js +118 -0
- package/src/server/api/mcp.js +336 -0
- package/src/server/api/pm2-autostart.js +269 -0
- package/src/server/api/projects.js +124 -0
- package/src/server/api/prompts.js +279 -0
- package/src/server/api/proxy.js +235 -0
- package/src/server/api/rules.js +271 -0
- package/src/server/api/sessions.js +595 -0
- package/src/server/api/settings.js +61 -0
- package/src/server/api/skills.js +305 -0
- package/src/server/api/statistics.js +91 -0
- package/src/server/api/terminal.js +202 -0
- package/src/server/api/ui-config.js +64 -0
- package/src/server/api/workspaces.js +407 -0
- package/src/server/codex-proxy-server.js +538 -0
- package/src/server/dev-server.js +26 -0
- package/src/server/gemini-proxy-server.js +518 -0
- package/src/server/index.js +305 -0
- package/src/server/proxy-server.js +469 -0
- package/src/server/services/agents-service.js +354 -0
- package/src/server/services/alias.js +71 -0
- package/src/server/services/channel-health.js +234 -0
- package/src/server/services/channel-scheduler.js +234 -0
- package/src/server/services/channels.js +347 -0
- package/src/server/services/codex-channels.js +625 -0
- package/src/server/services/codex-config.js +90 -0
- package/src/server/services/codex-parser.js +322 -0
- package/src/server/services/codex-sessions.js +665 -0
- package/src/server/services/codex-settings-manager.js +397 -0
- package/src/server/services/codex-speed-test-template.json +24 -0
- package/src/server/services/codex-statistics-service.js +255 -0
- package/src/server/services/commands-service.js +360 -0
- package/src/server/services/config-templates-service.js +732 -0
- package/src/server/services/env-checker.js +307 -0
- package/src/server/services/env-manager.js +300 -0
- package/src/server/services/favorites.js +163 -0
- package/src/server/services/gemini-channels.js +333 -0
- package/src/server/services/gemini-config.js +73 -0
- package/src/server/services/gemini-sessions.js +689 -0
- package/src/server/services/gemini-settings-manager.js +263 -0
- package/src/server/services/gemini-statistics-service.js +253 -0
- package/src/server/services/health-check.js +399 -0
- package/src/server/services/mcp-service.js +1188 -0
- package/src/server/services/prompts-service.js +492 -0
- package/src/server/services/proxy-runtime.js +79 -0
- package/src/server/services/pty-manager.js +435 -0
- package/src/server/services/rules-service.js +401 -0
- package/src/server/services/session-cache.js +127 -0
- package/src/server/services/session-converter.js +577 -0
- package/src/server/services/sessions.js +757 -0
- package/src/server/services/settings-manager.js +163 -0
- package/src/server/services/skill-service.js +965 -0
- package/src/server/services/speed-test.js +545 -0
- package/src/server/services/statistics-service.js +386 -0
- package/src/server/services/terminal-commands.js +155 -0
- package/src/server/services/terminal-config.js +140 -0
- package/src/server/services/terminal-detector.js +306 -0
- package/src/server/services/ui-config.js +130 -0
- package/src/server/services/workspace-service.js +662 -0
- package/src/server/utils/pricing.js +41 -0
- package/src/server/websocket-server.js +557 -0
- package/src/ui/menu.js +129 -0
- package/src/ui/prompts.js +100 -0
- package/src/utils/format.js +43 -0
- package/src/utils/port-helper.js +94 -0
- package/src/utils/session.js +239 -0
|
@@ -0,0 +1,538 @@
|
|
|
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 { resolvePricing } = require('./utils/pricing');
|
|
11
|
+
const { recordRequest: recordCodexRequest } = require('./services/codex-statistics-service');
|
|
12
|
+
const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
|
|
13
|
+
const { getEnabledChannels, writeCodexConfigForMultiChannel } = require('./services/codex-channels');
|
|
14
|
+
|
|
15
|
+
let proxyServer = null;
|
|
16
|
+
let proxyApp = null;
|
|
17
|
+
let currentPort = null;
|
|
18
|
+
|
|
19
|
+
// 用于存储每个请求的元数据
|
|
20
|
+
const requestMetadata = new Map();
|
|
21
|
+
|
|
22
|
+
// OpenAI 模型定价(每百万 tokens 的价格,单位:美元)
|
|
23
|
+
const PRICING = {
|
|
24
|
+
'gpt-4o': { input: 2.5, output: 10 },
|
|
25
|
+
'gpt-4o-2024-11-20': { input: 2.5, output: 10 },
|
|
26
|
+
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
|
27
|
+
'gpt-4-turbo': { input: 10, output: 30 },
|
|
28
|
+
'gpt-4': { input: 30, output: 60 },
|
|
29
|
+
'gpt-3.5-turbo': { input: 0.5, output: 1.5 },
|
|
30
|
+
'o1': { input: 15, output: 60 },
|
|
31
|
+
'o1-mini': { input: 3, output: 12 },
|
|
32
|
+
'o1-pro': { input: 150, output: 600 },
|
|
33
|
+
'o3': { input: 10, output: 40 },
|
|
34
|
+
'o3-mini': { input: 1.1, output: 4.4 },
|
|
35
|
+
'o4-mini': { input: 1.1, output: 4.4 },
|
|
36
|
+
// Claude 模型(通过 OpenAI 格式访问)
|
|
37
|
+
'claude-sonnet-4-5-20250929': { input: 3, output: 15 },
|
|
38
|
+
'claude-sonnet-4-20250514': { input: 3, output: 15 },
|
|
39
|
+
'claude-opus-4-20250514': { input: 15, output: 75 },
|
|
40
|
+
'claude-3-5-sonnet-20241022': { input: 3, output: 15 },
|
|
41
|
+
'claude-3-5-haiku-20241022': { input: 0.8, output: 4 }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const CODEX_BASE_PRICING = DEFAULT_CONFIG.pricing.codex;
|
|
45
|
+
const ONE_MILLION = 1000000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 解析 Codex 代理目标 URL
|
|
49
|
+
*
|
|
50
|
+
* Codex CLI 发送请求到我们的代理时,请求路径格式:
|
|
51
|
+
* - /v1/responses (OpenAI Responses API)
|
|
52
|
+
* - /v1/chat/completions (OpenAI Chat Completions API)
|
|
53
|
+
*
|
|
54
|
+
* 渠道配置的 base_url 可能是:
|
|
55
|
+
* - https://api.openai.com/v1
|
|
56
|
+
* - https://example.com/openai/v1
|
|
57
|
+
* - https://example.com
|
|
58
|
+
*
|
|
59
|
+
* 最终转发目标示例:
|
|
60
|
+
* - base_url: https://example.com/openai/v1, path: /v1/responses
|
|
61
|
+
* -> target: https://example.com/openai, 最终: https://example.com/openai/v1/responses
|
|
62
|
+
*
|
|
63
|
+
* 这个函数返回要传给 http-proxy 的 target,http-proxy 会自动拼接 req.url
|
|
64
|
+
*/
|
|
65
|
+
function resolveCodexTarget(baseUrl = '', requestPath = '') {
|
|
66
|
+
let target = baseUrl || '';
|
|
67
|
+
|
|
68
|
+
// 移除末尾斜杠
|
|
69
|
+
if (target.endsWith('/')) {
|
|
70
|
+
target = target.slice(0, -1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 核心逻辑:避免 /v1/v1 重复
|
|
74
|
+
// 如果 base_url 以 /v1 结尾,且请求路径以 /v1 开头,去掉 base_url 的 /v1
|
|
75
|
+
// 因为 http-proxy 会将 requestPath 追加到 target 后面
|
|
76
|
+
if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
|
|
77
|
+
target = target.slice(0, -3);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 计算请求成本
|
|
85
|
+
*/
|
|
86
|
+
function calculateCost(model, tokens) {
|
|
87
|
+
// 尝试精确匹配
|
|
88
|
+
let pricing = PRICING[model];
|
|
89
|
+
|
|
90
|
+
// 如果没有精确匹配,尝试模糊匹配
|
|
91
|
+
if (!pricing) {
|
|
92
|
+
const modelLower = model.toLowerCase();
|
|
93
|
+
if (modelLower.includes('gpt-4o-mini')) {
|
|
94
|
+
pricing = PRICING['gpt-4o-mini'];
|
|
95
|
+
} else if (modelLower.includes('gpt-4o')) {
|
|
96
|
+
pricing = PRICING['gpt-4o'];
|
|
97
|
+
} else if (modelLower.includes('gpt-4')) {
|
|
98
|
+
pricing = PRICING['gpt-4'];
|
|
99
|
+
} else if (modelLower.includes('gpt-3.5')) {
|
|
100
|
+
pricing = PRICING['gpt-3.5-turbo'];
|
|
101
|
+
} else if (modelLower.includes('o1-mini')) {
|
|
102
|
+
pricing = PRICING['o1-mini'];
|
|
103
|
+
} else if (modelLower.includes('o1-pro')) {
|
|
104
|
+
pricing = PRICING['o1-pro'];
|
|
105
|
+
} else if (modelLower.includes('o1')) {
|
|
106
|
+
pricing = PRICING['o1'];
|
|
107
|
+
} else if (modelLower.includes('o3-mini')) {
|
|
108
|
+
pricing = PRICING['o3-mini'];
|
|
109
|
+
} else if (modelLower.includes('o3')) {
|
|
110
|
+
pricing = PRICING['o3'];
|
|
111
|
+
} else if (modelLower.includes('o4-mini')) {
|
|
112
|
+
pricing = PRICING['o4-mini'];
|
|
113
|
+
} else if (modelLower.includes('claude')) {
|
|
114
|
+
// Claude 模型默认使用 Sonnet 定价
|
|
115
|
+
pricing = PRICING['claude-sonnet-4-5-20250929'];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 默认使用基础定价
|
|
120
|
+
pricing = resolvePricing('codex', pricing, CODEX_BASE_PRICING);
|
|
121
|
+
const inputRate = typeof pricing.input === 'number' ? pricing.input : CODEX_BASE_PRICING.input;
|
|
122
|
+
const outputRate = typeof pricing.output === 'number' ? pricing.output : CODEX_BASE_PRICING.output;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
(tokens.input || 0) * inputRate / ONE_MILLION +
|
|
126
|
+
(tokens.output || 0) * outputRate / ONE_MILLION
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 启动 Codex 代理服务器
|
|
131
|
+
async function startCodexProxyServer(options = {}) {
|
|
132
|
+
// options.preserveStartTime - 是否保留现有的启动时间(用于切换渠道时)
|
|
133
|
+
const preserveStartTime = options.preserveStartTime || false;
|
|
134
|
+
|
|
135
|
+
if (proxyServer) {
|
|
136
|
+
console.log('Codex proxy server already running on port', currentPort);
|
|
137
|
+
return { success: true, port: currentPort };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const config = loadConfig();
|
|
142
|
+
const port = config.ports?.codexProxy || 10089;
|
|
143
|
+
currentPort = port;
|
|
144
|
+
|
|
145
|
+
proxyApp = express();
|
|
146
|
+
const proxy = httpProxy.createProxyServer({});
|
|
147
|
+
|
|
148
|
+
proxy.on('proxyReq', (proxyReq, req) => {
|
|
149
|
+
const activeChannel = req.selectedChannel;
|
|
150
|
+
if (!activeChannel) return;
|
|
151
|
+
|
|
152
|
+
const requestId = `codex-${Date.now()}-${Math.random()}`;
|
|
153
|
+
requestMetadata.set(req, {
|
|
154
|
+
id: requestId,
|
|
155
|
+
channel: activeChannel.name,
|
|
156
|
+
channelId: activeChannel.id,
|
|
157
|
+
startTime: Date.now()
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
proxyReq.removeHeader('authorization');
|
|
161
|
+
proxyReq.setHeader('authorization', `Bearer ${activeChannel.apiKey}`);
|
|
162
|
+
proxyReq.setHeader('openai-beta', 'responses=experimental');
|
|
163
|
+
if (!proxyReq.getHeader('content-type')) {
|
|
164
|
+
proxyReq.setHeader('content-type', 'application/json');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
proxyApp.use(async (req, res) => {
|
|
169
|
+
try {
|
|
170
|
+
const channel = await allocateChannel({ source: 'codex', enableSessionBinding: false });
|
|
171
|
+
req.selectedChannel = channel;
|
|
172
|
+
|
|
173
|
+
const release = (() => {
|
|
174
|
+
let released = false;
|
|
175
|
+
return () => {
|
|
176
|
+
if (released) return;
|
|
177
|
+
released = true;
|
|
178
|
+
releaseChannel(channel.id, 'codex');
|
|
179
|
+
broadcastSchedulerState('codex', getSchedulerState('codex'));
|
|
180
|
+
};
|
|
181
|
+
})();
|
|
182
|
+
|
|
183
|
+
res.on('close', release);
|
|
184
|
+
res.on('error', release);
|
|
185
|
+
|
|
186
|
+
broadcastSchedulerState('codex', getSchedulerState('codex'));
|
|
187
|
+
|
|
188
|
+
const target = resolveCodexTarget(channel.baseUrl, req.url);
|
|
189
|
+
|
|
190
|
+
proxy.web(req, res, {
|
|
191
|
+
target,
|
|
192
|
+
changeOrigin: true,
|
|
193
|
+
proxyTimeout: 120000, // 代理连接超时 2 分钟
|
|
194
|
+
timeout: 120000 // 请求超时 2 分钟
|
|
195
|
+
}, (err) => {
|
|
196
|
+
release();
|
|
197
|
+
if (err) {
|
|
198
|
+
recordFailure(channel.id, 'codex', err);
|
|
199
|
+
console.error('Codex proxy error:', err);
|
|
200
|
+
if (res && !res.headersSent) {
|
|
201
|
+
res.status(502).json({
|
|
202
|
+
error: {
|
|
203
|
+
message: 'Proxy error: ' + err.message,
|
|
204
|
+
type: 'proxy_error'
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Codex channel allocation error:', error);
|
|
212
|
+
if (!res.headersSent) {
|
|
213
|
+
res.status(503).json({
|
|
214
|
+
error: {
|
|
215
|
+
message: error.message || 'No Codex channel available',
|
|
216
|
+
type: 'channel_pool_exhausted'
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// 监听代理响应 (OpenAI 格式)
|
|
224
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
225
|
+
const metadata = requestMetadata.get(req);
|
|
226
|
+
if (!metadata) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 检查响应是否已关闭
|
|
231
|
+
if (res.writableEnded || res.destroyed) {
|
|
232
|
+
requestMetadata.delete(req);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 标记响应是否已关闭
|
|
237
|
+
let isResponseClosed = false;
|
|
238
|
+
|
|
239
|
+
// 监听响应关闭事件
|
|
240
|
+
res.on('close', () => {
|
|
241
|
+
isResponseClosed = true;
|
|
242
|
+
requestMetadata.delete(req);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// 监听响应错误事件
|
|
246
|
+
res.on('error', (err) => {
|
|
247
|
+
isResponseClosed = true;
|
|
248
|
+
// 忽略客户端断开连接的常见错误
|
|
249
|
+
if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
|
|
250
|
+
console.error('Response error:', err);
|
|
251
|
+
}
|
|
252
|
+
requestMetadata.delete(req);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
let buffer = '';
|
|
256
|
+
let tokenData = {
|
|
257
|
+
inputTokens: 0,
|
|
258
|
+
outputTokens: 0,
|
|
259
|
+
cachedTokens: 0,
|
|
260
|
+
reasoningTokens: 0,
|
|
261
|
+
totalTokens: 0,
|
|
262
|
+
model: ''
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
proxyRes.on('data', (chunk) => {
|
|
266
|
+
// 如果响应已关闭,停止处理
|
|
267
|
+
if (isResponseClosed) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
buffer += chunk.toString();
|
|
272
|
+
|
|
273
|
+
// 检查是否是 SSE 流
|
|
274
|
+
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
|
275
|
+
// 处理 SSE 事件
|
|
276
|
+
const events = buffer.split('\n\n');
|
|
277
|
+
buffer = events.pop() || '';
|
|
278
|
+
|
|
279
|
+
events.forEach((eventText, index) => {
|
|
280
|
+
if (!eventText.trim()) return;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const lines = eventText.split('\n');
|
|
284
|
+
let data = '';
|
|
285
|
+
|
|
286
|
+
lines.forEach(line => {
|
|
287
|
+
if (line.startsWith('data:')) {
|
|
288
|
+
data = line.substring(5).trim();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!data) return;
|
|
293
|
+
|
|
294
|
+
if (data === '[DONE]') return;
|
|
295
|
+
|
|
296
|
+
const parsed = JSON.parse(data);
|
|
297
|
+
|
|
298
|
+
// OpenAI Responses API: 在 response.completed 事件中获取 usage
|
|
299
|
+
if (parsed.type === 'response.completed' && parsed.response) {
|
|
300
|
+
// 从 response 对象中提取模型和 usage
|
|
301
|
+
if (parsed.response.model) {
|
|
302
|
+
tokenData.model = parsed.response.model;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (parsed.response.usage) {
|
|
306
|
+
tokenData.inputTokens = parsed.response.usage.input_tokens || 0;
|
|
307
|
+
tokenData.outputTokens = parsed.response.usage.output_tokens || 0;
|
|
308
|
+
tokenData.totalTokens = parsed.response.usage.total_tokens || 0;
|
|
309
|
+
|
|
310
|
+
// 提取详细信息
|
|
311
|
+
if (parsed.response.usage.input_tokens_details) {
|
|
312
|
+
tokenData.cachedTokens = parsed.response.usage.input_tokens_details.cached_tokens || 0;
|
|
313
|
+
}
|
|
314
|
+
if (parsed.response.usage.output_tokens_details) {
|
|
315
|
+
tokenData.reasoningTokens = parsed.response.usage.output_tokens_details.reasoning_tokens || 0;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 兼容其他格式:直接在顶层的 model 和 usage
|
|
321
|
+
if (parsed.model && !tokenData.model) {
|
|
322
|
+
tokenData.model = parsed.model;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (parsed.usage && tokenData.inputTokens === 0) {
|
|
326
|
+
// 兼容 Responses API 和 Chat Completions API
|
|
327
|
+
tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
|
|
328
|
+
tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
|
|
329
|
+
}
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// 忽略解析错误
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
proxyRes.on('end', () => {
|
|
338
|
+
// 如果不是流式响应,尝试从完整响应中解析
|
|
339
|
+
if (!proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(buffer);
|
|
342
|
+
if (parsed.model) {
|
|
343
|
+
tokenData.model = parsed.model;
|
|
344
|
+
}
|
|
345
|
+
if (parsed.usage) {
|
|
346
|
+
// 兼容两种格式
|
|
347
|
+
tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
|
|
348
|
+
tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// 忽略解析错误
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 只有当有 token 数据时才记录
|
|
356
|
+
if (tokenData.inputTokens > 0 || tokenData.outputTokens > 0) {
|
|
357
|
+
const now = new Date();
|
|
358
|
+
const time = now.toLocaleTimeString('zh-CN', {
|
|
359
|
+
hour12: false,
|
|
360
|
+
hour: '2-digit',
|
|
361
|
+
minute: '2-digit',
|
|
362
|
+
second: '2-digit'
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// 记录统计数据(先计算)
|
|
366
|
+
const tokens = {
|
|
367
|
+
input: tokenData.inputTokens,
|
|
368
|
+
output: tokenData.outputTokens,
|
|
369
|
+
total: tokenData.inputTokens + tokenData.outputTokens
|
|
370
|
+
};
|
|
371
|
+
const cost = calculateCost(tokenData.model, tokens);
|
|
372
|
+
|
|
373
|
+
// 广播日志(仅当响应仍然开放时)
|
|
374
|
+
if (!isResponseClosed) {
|
|
375
|
+
broadcastLog({
|
|
376
|
+
type: 'log',
|
|
377
|
+
id: metadata.id,
|
|
378
|
+
time: time,
|
|
379
|
+
channel: metadata.channel,
|
|
380
|
+
model: tokenData.model,
|
|
381
|
+
inputTokens: tokenData.inputTokens,
|
|
382
|
+
outputTokens: tokenData.outputTokens,
|
|
383
|
+
cachedTokens: tokenData.cachedTokens,
|
|
384
|
+
reasoningTokens: tokenData.reasoningTokens,
|
|
385
|
+
totalTokens: tokenData.totalTokens,
|
|
386
|
+
cost: cost,
|
|
387
|
+
source: 'codex'
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const duration = Date.now() - metadata.startTime;
|
|
392
|
+
|
|
393
|
+
recordCodexRequest({
|
|
394
|
+
id: metadata.id,
|
|
395
|
+
timestamp: new Date(metadata.startTime).toISOString(),
|
|
396
|
+
toolType: 'codex',
|
|
397
|
+
channel: metadata.channel,
|
|
398
|
+
channelId: metadata.channelId,
|
|
399
|
+
model: tokenData.model,
|
|
400
|
+
tokens: {
|
|
401
|
+
input: tokenData.inputTokens,
|
|
402
|
+
output: tokenData.outputTokens,
|
|
403
|
+
reasoning: tokenData.reasoningTokens,
|
|
404
|
+
cached: tokenData.cachedTokens,
|
|
405
|
+
total: tokens.total
|
|
406
|
+
},
|
|
407
|
+
duration: duration,
|
|
408
|
+
success: true,
|
|
409
|
+
cost: cost
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
recordSuccess(metadata.channelId, 'codex');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!isResponseClosed) {
|
|
416
|
+
requestMetadata.delete(req);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
proxyRes.on('error', (err) => {
|
|
421
|
+
// 忽略代理响应错误(可能是网络问题)
|
|
422
|
+
if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
|
|
423
|
+
console.error('Proxy response error:', err);
|
|
424
|
+
}
|
|
425
|
+
isResponseClosed = true;
|
|
426
|
+
recordFailure(metadata.channelId, 'codex', err);
|
|
427
|
+
requestMetadata.delete(req);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// 处理代理错误
|
|
432
|
+
proxy.on('error', (err, req, res) => {
|
|
433
|
+
console.error('Codex proxy error:', err);
|
|
434
|
+
if (req && req.selectedChannel) {
|
|
435
|
+
recordFailure(req.selectedChannel.id, 'codex', err);
|
|
436
|
+
releaseChannel(req.selectedChannel.id, 'codex');
|
|
437
|
+
broadcastSchedulerState('codex', getSchedulerState('codex'));
|
|
438
|
+
}
|
|
439
|
+
if (res && !res.headersSent) {
|
|
440
|
+
res.status(502).json({
|
|
441
|
+
error: {
|
|
442
|
+
message: 'Proxy error: ' + err.message,
|
|
443
|
+
type: 'proxy_error'
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// 启动服务器
|
|
450
|
+
proxyServer = http.createServer(proxyApp);
|
|
451
|
+
|
|
452
|
+
return new Promise((resolve, reject) => {
|
|
453
|
+
proxyServer.listen(port, '127.0.0.1', () => {
|
|
454
|
+
console.log(`Codex proxy server started on http://127.0.0.1:${port}`);
|
|
455
|
+
|
|
456
|
+
// 保存代理启动时间(如果是切换渠道,保留原有启动时间)
|
|
457
|
+
saveProxyStartTime('codex', preserveStartTime);
|
|
458
|
+
|
|
459
|
+
// 启动代理时同步配置到 Codex 的 config.toml
|
|
460
|
+
try {
|
|
461
|
+
const enabledChannels = getEnabledChannels();
|
|
462
|
+
if (enabledChannels.length > 0) {
|
|
463
|
+
writeCodexConfigForMultiChannel(enabledChannels);
|
|
464
|
+
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
// ignore sync error
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
resolve({ success: true, port });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
proxyServer.on('error', (err) => {
|
|
473
|
+
if (err.code === 'EADDRINUSE') {
|
|
474
|
+
console.error(chalk.red(`\nCodex proxy port ${port} is already in use`));
|
|
475
|
+
} else {
|
|
476
|
+
console.error('Failed to start Codex proxy server:', err);
|
|
477
|
+
}
|
|
478
|
+
proxyServer = null;
|
|
479
|
+
proxyApp = null;
|
|
480
|
+
currentPort = null;
|
|
481
|
+
reject(err);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
} catch (err) {
|
|
485
|
+
console.error('Error starting Codex proxy server:', err);
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 停止 Codex 代理服务器
|
|
491
|
+
async function stopCodexProxyServer(options = {}) {
|
|
492
|
+
// options.clearStartTime - 是否清除启动时间(默认 true)
|
|
493
|
+
const clearStartTime = options.clearStartTime !== false;
|
|
494
|
+
|
|
495
|
+
if (!proxyServer) {
|
|
496
|
+
return { success: true, message: 'Codex proxy server not running' };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
requestMetadata.clear();
|
|
500
|
+
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
proxyServer.close(() => {
|
|
503
|
+
console.log('Codex proxy server stopped');
|
|
504
|
+
|
|
505
|
+
// 清除代理启动时间(仅当明确要求时)
|
|
506
|
+
if (clearStartTime) {
|
|
507
|
+
clearProxyStartTime('codex');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
proxyServer = null;
|
|
511
|
+
proxyApp = null;
|
|
512
|
+
const stoppedPort = currentPort;
|
|
513
|
+
currentPort = null;
|
|
514
|
+
resolve({ success: true, port: stoppedPort });
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 获取代理服务器状态
|
|
520
|
+
function getCodexProxyStatus() {
|
|
521
|
+
const config = loadConfig();
|
|
522
|
+
const startTime = getProxyStartTime('codex');
|
|
523
|
+
const runtime = getProxyRuntime('codex');
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
running: !!proxyServer,
|
|
527
|
+
port: currentPort,
|
|
528
|
+
defaultPort: config.ports?.codexProxy || 10089,
|
|
529
|
+
startTime,
|
|
530
|
+
runtime
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
module.exports = {
|
|
535
|
+
startCodexProxyServer,
|
|
536
|
+
stopCodexProxyServer,
|
|
537
|
+
getCodexProxyStatus
|
|
538
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 开发环境后端服务器启动脚本
|
|
5
|
+
* 用于本地前端开发时启动后端 API 服务
|
|
6
|
+
* 配合 nodemon 实现热重载
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { startServer } = require('./index');
|
|
10
|
+
const { loadConfig } = require('../config/loader');
|
|
11
|
+
const chalk = require('chalk');
|
|
12
|
+
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const port = config.ports?.webUI || 10099;
|
|
15
|
+
|
|
16
|
+
console.log(chalk.cyan('\n🔧 开发模式:启动后端 API 服务器...\n'));
|
|
17
|
+
|
|
18
|
+
(async () => {
|
|
19
|
+
await startServer(port);
|
|
20
|
+
|
|
21
|
+
console.log(chalk.yellow('💡 开发提示:'));
|
|
22
|
+
console.log(chalk.gray(` - 后端 API: http://localhost:${port}/api`));
|
|
23
|
+
console.log(chalk.gray(' - 前端开发服务器: http://localhost:5000'));
|
|
24
|
+
console.log(chalk.gray(' - 修改后端代码会自动重启 (nodemon)'));
|
|
25
|
+
console.log(chalk.gray(' - 按 Ctrl+C 停止服务器\n'));
|
|
26
|
+
})();
|