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.
- package/CHANGELOG.md +599 -0
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/bin/ctx.js +8 -0
- package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
- package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
- package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
- package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
- package/dist/web/assets/Home-38JTUlYt.js +1 -0
- package/dist/web/assets/Home-CjupSEWE.css +1 -0
- package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
- package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
- package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
- package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
- package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
- package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
- package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
- package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
- package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
- package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
- package/dist/web/assets/icons-DRrXwWZi.js +1 -0
- package/dist/web/assets/index-CetESrXw.css +1 -0
- package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
- package/dist/web/assets/markdown-BfC0goYb.css +10 -0
- package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
- package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
- package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
- package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/index.html +20 -0
- package/dist/web/logo.png +0 -0
- package/docs/bannel.png +0 -0
- package/docs/home.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/model-redirection.md +251 -0
- package/docs/multi-channel-load-balancing.md +249 -0
- package/package.json +80 -0
- package/src/commands/channels.js +551 -0
- package/src/commands/cli-type.js +101 -0
- package/src/commands/daemon.js +365 -0
- package/src/commands/doctor.js +333 -0
- package/src/commands/export-config.js +205 -0
- package/src/commands/list.js +222 -0
- package/src/commands/logs.js +261 -0
- package/src/commands/plugin.js +585 -0
- package/src/commands/port-config.js +135 -0
- package/src/commands/proxy-control.js +264 -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/security.js +37 -0
- package/src/commands/stats.js +398 -0
- package/src/commands/switch.js +48 -0
- package/src/commands/toggle-proxy.js +247 -0
- package/src/commands/ui.js +99 -0
- package/src/commands/update.js +97 -0
- package/src/commands/workspace.js +454 -0
- package/src/config/default.js +69 -0
- package/src/config/loader.js +149 -0
- package/src/config/model-metadata.js +167 -0
- package/src/config/model-metadata.json +125 -0
- package/src/config/model-pricing.js +35 -0
- package/src/config/paths.js +190 -0
- package/src/index.js +680 -0
- package/src/plugins/constants.js +15 -0
- package/src/plugins/event-bus.js +54 -0
- package/src/plugins/manifest-validator.js +129 -0
- package/src/plugins/plugin-api.js +128 -0
- package/src/plugins/plugin-installer.js +601 -0
- package/src/plugins/plugin-loader.js +229 -0
- package/src/plugins/plugin-manager.js +170 -0
- package/src/plugins/registry.js +152 -0
- package/src/plugins/schema/plugin-manifest.json +115 -0
- package/src/reset-config.js +94 -0
- package/src/server/api/agents.js +826 -0
- package/src/server/api/aliases.js +36 -0
- package/src/server/api/channels.js +368 -0
- package/src/server/api/claude-hooks.js +480 -0
- package/src/server/api/codex-channels.js +417 -0
- package/src/server/api/codex-projects.js +104 -0
- package/src/server/api/codex-proxy.js +195 -0
- package/src/server/api/codex-sessions.js +483 -0
- package/src/server/api/codex-statistics.js +57 -0
- package/src/server/api/commands.js +482 -0
- package/src/server/api/config-export.js +212 -0
- package/src/server/api/config-registry.js +357 -0
- package/src/server/api/config-sync.js +155 -0
- package/src/server/api/config-templates.js +248 -0
- package/src/server/api/config.js +521 -0
- package/src/server/api/convert.js +260 -0
- package/src/server/api/dashboard.js +142 -0
- package/src/server/api/env.js +144 -0
- package/src/server/api/favorites.js +77 -0
- package/src/server/api/gemini-channels.js +366 -0
- package/src/server/api/gemini-projects.js +91 -0
- package/src/server/api/gemini-proxy.js +173 -0
- package/src/server/api/gemini-sessions.js +376 -0
- package/src/server/api/gemini-statistics.js +57 -0
- package/src/server/api/health-check.js +31 -0
- package/src/server/api/mcp.js +399 -0
- package/src/server/api/opencode-channels.js +419 -0
- package/src/server/api/opencode-projects.js +99 -0
- package/src/server/api/opencode-proxy.js +207 -0
- package/src/server/api/opencode-sessions.js +327 -0
- package/src/server/api/opencode-statistics.js +57 -0
- package/src/server/api/plugins.js +463 -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 +306 -0
- package/src/server/api/security.js +53 -0
- package/src/server/api/sessions.js +514 -0
- package/src/server/api/settings.js +142 -0
- package/src/server/api/skills.js +570 -0
- package/src/server/api/statistics.js +238 -0
- package/src/server/api/ui-config.js +64 -0
- package/src/server/api/workspaces.js +456 -0
- package/src/server/codex-proxy-server.js +681 -0
- package/src/server/dev-server.js +26 -0
- package/src/server/gemini-proxy-server.js +610 -0
- package/src/server/index.js +422 -0
- package/src/server/opencode-proxy-server.js +4771 -0
- package/src/server/proxy-server.js +669 -0
- package/src/server/services/agents-service.js +1137 -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 +240 -0
- package/src/server/services/channels.js +447 -0
- package/src/server/services/codex-channels.js +705 -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 +936 -0
- package/src/server/services/codex-settings-manager.js +619 -0
- package/src/server/services/codex-speed-test-template.json +24 -0
- package/src/server/services/codex-statistics-service.js +161 -0
- package/src/server/services/commands-service.js +574 -0
- package/src/server/services/config-export-service.js +1165 -0
- package/src/server/services/config-registry-service.js +828 -0
- package/src/server/services/config-sync-manager.js +941 -0
- package/src/server/services/config-sync-service.js +504 -0
- package/src/server/services/config-templates-service.js +913 -0
- package/src/server/services/enhanced-cache.js +196 -0
- package/src/server/services/env-checker.js +409 -0
- package/src/server/services/env-manager.js +436 -0
- package/src/server/services/favorites.js +165 -0
- package/src/server/services/format-converter.js +620 -0
- package/src/server/services/gemini-channels.js +459 -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 +157 -0
- package/src/server/services/health-check.js +85 -0
- package/src/server/services/mcp-client.js +790 -0
- package/src/server/services/mcp-service.js +1732 -0
- package/src/server/services/model-detector.js +1245 -0
- package/src/server/services/network-access.js +80 -0
- package/src/server/services/opencode-channels.js +366 -0
- package/src/server/services/opencode-gateway-adapters.js +1168 -0
- package/src/server/services/opencode-gateway-converter.js +639 -0
- package/src/server/services/opencode-sessions.js +931 -0
- package/src/server/services/opencode-settings-manager.js +478 -0
- package/src/server/services/opencode-statistics-service.js +161 -0
- package/src/server/services/plugins-service.js +1268 -0
- package/src/server/services/prompts-service.js +534 -0
- package/src/server/services/proxy-runtime.js +79 -0
- package/src/server/services/repo-scanner-base.js +708 -0
- package/src/server/services/request-logger.js +130 -0
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/security-config.js +131 -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 +900 -0
- package/src/server/services/settings-manager.js +163 -0
- package/src/server/services/skill-service.js +1482 -0
- package/src/server/services/speed-test.js +1146 -0
- package/src/server/services/statistics-service.js +1043 -0
- package/src/server/services/ui-config.js +132 -0
- package/src/server/services/workspace-service.js +830 -0
- package/src/server/utils/pricing.js +73 -0
- package/src/server/websocket-server.js +513 -0
- package/src/ui/menu.js +139 -0
- package/src/ui/prompts.js +100 -0
- package/src/utils/format.js +43 -0
- package/src/utils/port-helper.js +108 -0
- package/src/utils/session.js +240 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const httpProxy = require('http-proxy');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const { HttpsProxyAgent } = require('https-proxy-agent');
|
|
9
|
+
const { allocateChannel, releaseChannel, getSchedulerState } = require('./services/channel-scheduler');
|
|
10
|
+
const { recordSuccess, recordFailure } = require('./services/channel-health');
|
|
11
|
+
const { broadcastLog, broadcastSchedulerState } = require('./websocket-server');
|
|
12
|
+
const { loadConfig } = require('../config/loader');
|
|
13
|
+
const DEFAULT_CONFIG = require('../config/default');
|
|
14
|
+
const { resolveModelPricing } = require('./utils/pricing');
|
|
15
|
+
const { recordRequest } = require('./services/statistics-service');
|
|
16
|
+
const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
|
|
17
|
+
const { createDecodedStream } = require('./services/response-decoder');
|
|
18
|
+
const eventBus = require('../plugins/event-bus');
|
|
19
|
+
const { getEffectiveApiKey } = require('./services/channels');
|
|
20
|
+
const { persistProxyRequestSnapshot } = require('./services/request-logger');
|
|
21
|
+
|
|
22
|
+
let proxyServer = null;
|
|
23
|
+
let proxyApp = null;
|
|
24
|
+
let currentPort = null;
|
|
25
|
+
|
|
26
|
+
// 用于存储每个请求的元数据(用于 WebSocket 日志)
|
|
27
|
+
const requestMetadata = new Map();
|
|
28
|
+
|
|
29
|
+
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
30
|
+
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
31
|
+
const printedRedirectCache = new Map();
|
|
32
|
+
|
|
33
|
+
const CLAUDE_BASE_PRICING = DEFAULT_CONFIG.pricing.claude;
|
|
34
|
+
const ONE_MILLION = 1000000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 检测模型层级
|
|
38
|
+
* @param {string} modelName - 模型名称
|
|
39
|
+
* @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
|
|
40
|
+
*/
|
|
41
|
+
function detectModelTier(modelName) {
|
|
42
|
+
if (!modelName) return null;
|
|
43
|
+
const lower = modelName.toLowerCase();
|
|
44
|
+
if (lower.includes('opus')) return 'opus';
|
|
45
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
46
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 应用模型重定向
|
|
52
|
+
* @param {string} originalModel - 原始模型名称
|
|
53
|
+
* @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
|
|
54
|
+
* @returns {string} 重定向后的模型名称
|
|
55
|
+
*/
|
|
56
|
+
function redirectModel(originalModel, channel) {
|
|
57
|
+
if (!originalModel) return originalModel;
|
|
58
|
+
|
|
59
|
+
// 优先使用新的 modelRedirects 数组格式
|
|
60
|
+
const modelRedirects = channel?.modelRedirects;
|
|
61
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
62
|
+
for (const rule of modelRedirects) {
|
|
63
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
64
|
+
return rule.to;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 向后兼容:使用旧的 modelConfig 格式
|
|
70
|
+
const modelConfig = channel?.modelConfig;
|
|
71
|
+
if (!modelConfig) return originalModel;
|
|
72
|
+
|
|
73
|
+
const tier = detectModelTier(originalModel);
|
|
74
|
+
|
|
75
|
+
// 优先级:层级特定配置 > 通用模型覆盖
|
|
76
|
+
if (tier === 'opus' && modelConfig.opusModel) {
|
|
77
|
+
return modelConfig.opusModel;
|
|
78
|
+
}
|
|
79
|
+
if (tier === 'sonnet' && modelConfig.sonnetModel) {
|
|
80
|
+
return modelConfig.sonnetModel;
|
|
81
|
+
}
|
|
82
|
+
if (tier === 'haiku' && modelConfig.haikuModel) {
|
|
83
|
+
return modelConfig.haikuModel;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 回退到通用模型覆盖
|
|
87
|
+
if (modelConfig.model) {
|
|
88
|
+
return modelConfig.model;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return originalModel;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 计算请求成本
|
|
96
|
+
* @param {string} model - 模型名称
|
|
97
|
+
* @param {object} tokens - token 使用情况
|
|
98
|
+
* @returns {number} 成本(美元)
|
|
99
|
+
*/
|
|
100
|
+
function calculateCost(model, tokens) {
|
|
101
|
+
const pricing = resolveModelPricing('claude', model, {}, CLAUDE_BASE_PRICING);
|
|
102
|
+
|
|
103
|
+
const inputRate = typeof pricing.input === 'number' ? pricing.input : CLAUDE_BASE_PRICING.input;
|
|
104
|
+
const outputRate = typeof pricing.output === 'number' ? pricing.output : CLAUDE_BASE_PRICING.output;
|
|
105
|
+
const cacheCreationRate = typeof pricing.cacheCreation === 'number' ? pricing.cacheCreation : CLAUDE_BASE_PRICING.cacheCreation;
|
|
106
|
+
const cacheReadRate = typeof pricing.cacheRead === 'number' ? pricing.cacheRead : CLAUDE_BASE_PRICING.cacheRead;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
(tokens.input || 0) * inputRate / ONE_MILLION +
|
|
110
|
+
(tokens.output || 0) * outputRate / ONE_MILLION +
|
|
111
|
+
(tokens.cacheCreation || 0) * cacheCreationRate / ONE_MILLION +
|
|
112
|
+
(tokens.cacheRead || 0) * cacheReadRate / ONE_MILLION
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const jsonBodyParser = express.json({
|
|
117
|
+
limit: '100mb',
|
|
118
|
+
verify: (req, res, buf) => {
|
|
119
|
+
req.rawBody = Buffer.from(buf);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function shouldParseJson(req) {
|
|
124
|
+
const contentType = req.headers['content-type'] || '';
|
|
125
|
+
return req.method === 'POST' && contentType.includes('application/json');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function extractSessionIdFromBody(body = {}) {
|
|
129
|
+
if (!body || typeof body !== 'object') return null;
|
|
130
|
+
return (
|
|
131
|
+
body.session_id ||
|
|
132
|
+
body.sessionId ||
|
|
133
|
+
body.conversation_id ||
|
|
134
|
+
body.conversationId ||
|
|
135
|
+
body.metadata?.session_id ||
|
|
136
|
+
body.metadata?.sessionId ||
|
|
137
|
+
body.metadata?.conversation_id ||
|
|
138
|
+
body.workspace?.workspace_id ||
|
|
139
|
+
body.project_id ||
|
|
140
|
+
null
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function extractSessionId(req) {
|
|
145
|
+
const headerSession =
|
|
146
|
+
req.headers['x-session-id'] ||
|
|
147
|
+
req.headers['x-claude-session'] ||
|
|
148
|
+
req.headers['x-cc-session'];
|
|
149
|
+
if (headerSession) return String(headerSession);
|
|
150
|
+
if (req.body) {
|
|
151
|
+
return extractSessionIdFromBody(req.body);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function pickClaudeRequestHeaders(headers = {}) {
|
|
157
|
+
return { ...headers };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function toSerializable(value) {
|
|
161
|
+
if (value === undefined) return null;
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(JSON.stringify(value));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return String(value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractClaudeRequestBody(req) {
|
|
170
|
+
if (req?.body !== undefined) {
|
|
171
|
+
return req.body;
|
|
172
|
+
}
|
|
173
|
+
if (req?.rawBody) {
|
|
174
|
+
const bodyBuffer = Buffer.isBuffer(req.rawBody)
|
|
175
|
+
? req.rawBody
|
|
176
|
+
: Buffer.from(req.rawBody);
|
|
177
|
+
return bodyBuffer.toString('utf8');
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function serializeFullClaudeRequest(req) {
|
|
183
|
+
const rawBodyBuffer = req?.rawBody
|
|
184
|
+
? (Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody))
|
|
185
|
+
: null;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
method: req?.method || null,
|
|
189
|
+
url: req?.url || null,
|
|
190
|
+
originalUrl: req?.originalUrl || null,
|
|
191
|
+
path: req?.path || null,
|
|
192
|
+
httpVersion: req?.httpVersion || null,
|
|
193
|
+
headers: pickClaudeRequestHeaders(req?.headers || {}),
|
|
194
|
+
rawHeaders: Array.isArray(req?.rawHeaders) ? [...req.rawHeaders] : null,
|
|
195
|
+
query: toSerializable(req?.query),
|
|
196
|
+
params: toSerializable(req?.params),
|
|
197
|
+
body: toSerializable(extractClaudeRequestBody(req)),
|
|
198
|
+
rawBody: rawBodyBuffer ? rawBodyBuffer.toString('utf8') : null
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function persistClaudeRequestSnapshot(payload) {
|
|
203
|
+
persistProxyRequestSnapshot('claude', payload);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildClaudeRequestSummary(req, sessionId = null) {
|
|
207
|
+
const body = req && req.body && typeof req.body === 'object' ? req.body : {};
|
|
208
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
209
|
+
const contentLengthHeader = req?.headers?.['content-length'];
|
|
210
|
+
const contentLength = contentLengthHeader !== undefined && contentLengthHeader !== null
|
|
211
|
+
? Number(contentLengthHeader)
|
|
212
|
+
: (req?.rawBody ? req.rawBody.length : null);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
method: req?.method || null,
|
|
216
|
+
path: req?.originalUrl || req?.url || null,
|
|
217
|
+
model: body.model || null,
|
|
218
|
+
stream: body.stream === true,
|
|
219
|
+
maxTokens: body.max_tokens ?? body.maxTokens ?? null,
|
|
220
|
+
messageCount: messages.length,
|
|
221
|
+
hasSystem: body.system !== undefined && body.system !== null,
|
|
222
|
+
sessionId: sessionId || null,
|
|
223
|
+
contentLength: Number.isFinite(contentLength) ? contentLength : null
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function startProxyServer(options = {}) {
|
|
228
|
+
const preserveStartTime = options.preserveStartTime || false;
|
|
229
|
+
|
|
230
|
+
if (proxyServer) {
|
|
231
|
+
console.log('Proxy server already running on port', currentPort);
|
|
232
|
+
return { success: true, port: currentPort };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const config = loadConfig();
|
|
237
|
+
const port = config.ports?.proxy || 20088;
|
|
238
|
+
currentPort = port;
|
|
239
|
+
|
|
240
|
+
proxyApp = express();
|
|
241
|
+
|
|
242
|
+
proxyApp.use((req, res, next) => {
|
|
243
|
+
if (shouldParseJson(req)) {
|
|
244
|
+
return jsonBodyParser(req, res, next);
|
|
245
|
+
}
|
|
246
|
+
return next();
|
|
247
|
+
});
|
|
248
|
+
const proxy = httpProxy.createProxyServer({});
|
|
249
|
+
|
|
250
|
+
proxy.on('proxyReq', (proxyReq, req, res) => {
|
|
251
|
+
const selectedChannel = req.selectedChannel;
|
|
252
|
+
if (selectedChannel) {
|
|
253
|
+
const requestId = `${Date.now()}-${Math.random()}`;
|
|
254
|
+
requestMetadata.set(req, {
|
|
255
|
+
id: requestId,
|
|
256
|
+
channel: selectedChannel.name,
|
|
257
|
+
channelId: selectedChannel.id,
|
|
258
|
+
startTime: Date.now(),
|
|
259
|
+
sessionId: req.sessionId || null
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
proxyReq.removeHeader('x-api-key');
|
|
263
|
+
const effectiveKey = req.effectiveApiKey;
|
|
264
|
+
proxyReq.setHeader('x-api-key', effectiveKey);
|
|
265
|
+
proxyReq.removeHeader('authorization');
|
|
266
|
+
proxyReq.setHeader('authorization', `Bearer ${effectiveKey}`);
|
|
267
|
+
|
|
268
|
+
if (!proxyReq.getHeader('anthropic-version')) {
|
|
269
|
+
proxyReq.setHeader('anthropic-version', '2023-06-01');
|
|
270
|
+
}
|
|
271
|
+
if (!proxyReq.getHeader('content-type')) {
|
|
272
|
+
proxyReq.setHeader('content-type', 'application/json');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (shouldParseJson(req) && (req.rawBody || req.body)) {
|
|
277
|
+
const bodyBuffer = req.rawBody
|
|
278
|
+
? Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody)
|
|
279
|
+
: Buffer.from(JSON.stringify(req.body));
|
|
280
|
+
proxyReq.setHeader('Content-Length', bodyBuffer.length);
|
|
281
|
+
proxyReq.write(bodyBuffer);
|
|
282
|
+
proxyReq.end();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
proxyApp.use(async (req, res) => {
|
|
287
|
+
try {
|
|
288
|
+
const sessionId = extractSessionId(req);
|
|
289
|
+
const config = loadConfig();
|
|
290
|
+
const enableSessionBinding = config.enableSessionBinding !== false; // 默认开启
|
|
291
|
+
const channel = await allocateChannel({ source: 'claude', sessionId, enableSessionBinding });
|
|
292
|
+
|
|
293
|
+
// 广播调度状态(请求开始)
|
|
294
|
+
broadcastSchedulerState('claude', getSchedulerState('claude'));
|
|
295
|
+
|
|
296
|
+
req.selectedChannel = channel;
|
|
297
|
+
req.sessionId = sessionId || null;
|
|
298
|
+
let released = false;
|
|
299
|
+
|
|
300
|
+
const release = () => {
|
|
301
|
+
if (released) return;
|
|
302
|
+
released = true;
|
|
303
|
+
releaseChannel(channel.id, 'claude');
|
|
304
|
+
// 广播调度状态(请求结束)
|
|
305
|
+
broadcastSchedulerState('claude', getSchedulerState('claude'));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
req.__releaseChannel = release;
|
|
309
|
+
|
|
310
|
+
res.on('close', release);
|
|
311
|
+
res.on('error', release);
|
|
312
|
+
|
|
313
|
+
const effectiveKey = getEffectiveApiKey(channel);
|
|
314
|
+
if (!effectiveKey) {
|
|
315
|
+
release();
|
|
316
|
+
return res.status(401).json({
|
|
317
|
+
error: 'API key not configured or expired. Please update your channel key.',
|
|
318
|
+
type: 'authentication_error'
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
req.effectiveApiKey = effectiveKey;
|
|
322
|
+
|
|
323
|
+
const now = new Date();
|
|
324
|
+
const time = now.toLocaleTimeString('zh-CN', {
|
|
325
|
+
hour12: false,
|
|
326
|
+
hour: '2-digit',
|
|
327
|
+
minute: '2-digit',
|
|
328
|
+
second: '2-digit'
|
|
329
|
+
});
|
|
330
|
+
const requestSnapshot = serializeFullClaudeRequest(req);
|
|
331
|
+
broadcastLog({
|
|
332
|
+
type: 'action',
|
|
333
|
+
action: 'claude_request_received',
|
|
334
|
+
message: '收到 Claude Code 请求',
|
|
335
|
+
time,
|
|
336
|
+
channel: channel.name,
|
|
337
|
+
source: 'claude',
|
|
338
|
+
requestSummary: buildClaudeRequestSummary(req, sessionId)
|
|
339
|
+
});
|
|
340
|
+
persistClaudeRequestSnapshot({
|
|
341
|
+
timestamp: Date.now(),
|
|
342
|
+
source: 'claude',
|
|
343
|
+
channel: channel.name,
|
|
344
|
+
sessionId: sessionId || null,
|
|
345
|
+
request: requestSnapshot
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// 应用模型重定向(当 proxy 开启时)
|
|
349
|
+
if (req.body && req.body.model) {
|
|
350
|
+
const originalModel = req.body.model;
|
|
351
|
+
const redirectedModel = redirectModel(originalModel, channel);
|
|
352
|
+
|
|
353
|
+
if (redirectedModel !== originalModel) {
|
|
354
|
+
req.body.model = redirectedModel;
|
|
355
|
+
// 更新 rawBody 以匹配修改后的 body
|
|
356
|
+
req.rawBody = Buffer.from(JSON.stringify(req.body));
|
|
357
|
+
|
|
358
|
+
// 只在重定向规则变化时打印日志(避免每次请求都打印)
|
|
359
|
+
const cachedRedirects = printedRedirectCache.get(channel.id) || {};
|
|
360
|
+
if (cachedRedirects[originalModel] !== redirectedModel) {
|
|
361
|
+
cachedRedirects[originalModel] = redirectedModel;
|
|
362
|
+
printedRedirectCache.set(channel.id, cachedRedirects);
|
|
363
|
+
console.log(`[Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const proxyOptions = {
|
|
369
|
+
target: channel.baseUrl,
|
|
370
|
+
changeOrigin: true,
|
|
371
|
+
proxyTimeout: 120000, // 代理连接超时 2 分钟
|
|
372
|
+
timeout: 120000 // 请求超时 2 分钟
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (channel.proxyUrl) {
|
|
376
|
+
proxyOptions.agent = new HttpsProxyAgent(channel.proxyUrl);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
proxy.web(req, res, proxyOptions, (err) => {
|
|
380
|
+
release();
|
|
381
|
+
if (err) {
|
|
382
|
+
// 记录请求失败
|
|
383
|
+
recordFailure(channel.id, 'claude', err);
|
|
384
|
+
console.error('Proxy error:', err);
|
|
385
|
+
if (res && !res.headersSent) {
|
|
386
|
+
res.status(502).json({
|
|
387
|
+
error: 'Proxy error: ' + err.message,
|
|
388
|
+
type: 'proxy_error'
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error('Channel allocation error:', error);
|
|
395
|
+
if (!res.headersSent) {
|
|
396
|
+
res.status(503).json({
|
|
397
|
+
error: error.message || '所有渠道暂时不可用',
|
|
398
|
+
type: 'channel_pool_exhausted'
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
405
|
+
const metadata = requestMetadata.get(req);
|
|
406
|
+
if (!metadata) return;
|
|
407
|
+
|
|
408
|
+
if (res.writableEnded || res.destroyed) {
|
|
409
|
+
requestMetadata.delete(req);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let isResponseClosed = false;
|
|
414
|
+
|
|
415
|
+
res.on('close', () => {
|
|
416
|
+
isResponseClosed = true;
|
|
417
|
+
requestMetadata.delete(req);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
res.on('error', (err) => {
|
|
421
|
+
isResponseClosed = true;
|
|
422
|
+
if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
|
|
423
|
+
console.error('Response error:', err);
|
|
424
|
+
}
|
|
425
|
+
requestMetadata.delete(req);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
let buffer = '';
|
|
429
|
+
let tokenData = {
|
|
430
|
+
inputTokens: 0,
|
|
431
|
+
outputTokens: 0,
|
|
432
|
+
cacheCreation: 0,
|
|
433
|
+
cacheRead: 0,
|
|
434
|
+
model: ''
|
|
435
|
+
};
|
|
436
|
+
const parsedStream = createDecodedStream(proxyRes);
|
|
437
|
+
|
|
438
|
+
parsedStream.on('data', (chunk) => {
|
|
439
|
+
if (isResponseClosed) return;
|
|
440
|
+
|
|
441
|
+
buffer += chunk.toString('utf8');
|
|
442
|
+
|
|
443
|
+
const events = buffer.split('\n\n');
|
|
444
|
+
buffer = events.pop() || '';
|
|
445
|
+
|
|
446
|
+
events.forEach(eventText => {
|
|
447
|
+
if (!eventText.trim()) return;
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const lines = eventText.split('\n');
|
|
451
|
+
let eventType = '';
|
|
452
|
+
let data = '';
|
|
453
|
+
|
|
454
|
+
lines.forEach(line => {
|
|
455
|
+
if (line.startsWith('event:')) {
|
|
456
|
+
eventType = line.substring(6).trim();
|
|
457
|
+
} else if (line.startsWith('data:')) {
|
|
458
|
+
data = line.substring(5).trim();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
if (!data) return;
|
|
463
|
+
|
|
464
|
+
const parsed = JSON.parse(data);
|
|
465
|
+
|
|
466
|
+
if (eventType === 'message_start' && parsed.message && parsed.message.model) {
|
|
467
|
+
tokenData.model = parsed.message.model;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (parsed.usage) {
|
|
471
|
+
if (parsed.usage.input_tokens !== undefined) {
|
|
472
|
+
tokenData.inputTokens = parsed.usage.input_tokens;
|
|
473
|
+
}
|
|
474
|
+
if (parsed.usage.output_tokens !== undefined) {
|
|
475
|
+
tokenData.outputTokens = parsed.usage.output_tokens;
|
|
476
|
+
}
|
|
477
|
+
if (parsed.usage.cache_creation_input_tokens !== undefined) {
|
|
478
|
+
tokenData.cacheCreation = parsed.usage.cache_creation_input_tokens;
|
|
479
|
+
}
|
|
480
|
+
if (parsed.usage.cache_read_input_tokens !== undefined) {
|
|
481
|
+
tokenData.cacheRead = parsed.usage.cache_read_input_tokens;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (eventType === 'message_delta' && parsed.usage) {
|
|
486
|
+
const now = new Date();
|
|
487
|
+
const time = now.toLocaleTimeString('zh-CN', {
|
|
488
|
+
hour12: false,
|
|
489
|
+
hour: '2-digit',
|
|
490
|
+
minute: '2-digit',
|
|
491
|
+
second: '2-digit'
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const tokens = {
|
|
495
|
+
input: tokenData.inputTokens,
|
|
496
|
+
output: tokenData.outputTokens,
|
|
497
|
+
cacheCreation: tokenData.cacheCreation,
|
|
498
|
+
cacheRead: tokenData.cacheRead,
|
|
499
|
+
total: tokenData.inputTokens + tokenData.outputTokens + tokenData.cacheCreation + tokenData.cacheRead
|
|
500
|
+
};
|
|
501
|
+
const cost = calculateCost(tokenData.model, tokens);
|
|
502
|
+
|
|
503
|
+
if (!isResponseClosed) {
|
|
504
|
+
broadcastLog({
|
|
505
|
+
type: 'log',
|
|
506
|
+
id: metadata.id,
|
|
507
|
+
time: time,
|
|
508
|
+
channel: metadata.channel,
|
|
509
|
+
model: tokenData.model,
|
|
510
|
+
inputTokens: tokenData.inputTokens,
|
|
511
|
+
outputTokens: tokenData.outputTokens,
|
|
512
|
+
cacheCreation: tokenData.cacheCreation,
|
|
513
|
+
cacheRead: tokenData.cacheRead,
|
|
514
|
+
cost: cost,
|
|
515
|
+
source: 'claude'
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const duration = Date.now() - metadata.startTime;
|
|
520
|
+
|
|
521
|
+
recordRequest({
|
|
522
|
+
id: metadata.id,
|
|
523
|
+
timestamp: new Date(metadata.startTime).toISOString(),
|
|
524
|
+
toolType: 'claude-code',
|
|
525
|
+
channel: metadata.channel,
|
|
526
|
+
channelId: metadata.channelId,
|
|
527
|
+
model: tokenData.model,
|
|
528
|
+
tokens: tokens,
|
|
529
|
+
duration: duration,
|
|
530
|
+
success: true,
|
|
531
|
+
cost: cost
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// 记录请求成功(用于健康检查)
|
|
535
|
+
recordSuccess(metadata.channelId, 'claude');
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const finalize = () => {
|
|
543
|
+
if (!isResponseClosed) {
|
|
544
|
+
requestMetadata.delete(req);
|
|
545
|
+
}
|
|
546
|
+
if (typeof req.__releaseChannel === 'function') {
|
|
547
|
+
req.__releaseChannel();
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
parsedStream.on('end', finalize);
|
|
552
|
+
|
|
553
|
+
parsedStream.on('error', (err) => {
|
|
554
|
+
if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
|
|
555
|
+
console.error('Proxy response error:', err);
|
|
556
|
+
}
|
|
557
|
+
// 记录响应错误
|
|
558
|
+
if (metadata && metadata.channelId) {
|
|
559
|
+
recordFailure(metadata.channelId, 'claude', err);
|
|
560
|
+
}
|
|
561
|
+
isResponseClosed = true;
|
|
562
|
+
finalize();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
proxy.on('error', (err, req, res) => {
|
|
567
|
+
console.error('Proxy error:', err);
|
|
568
|
+
// 记录请求失败(用于健康检查)
|
|
569
|
+
if (req && req.selectedChannel && req.selectedChannel.id) {
|
|
570
|
+
recordFailure(req.selectedChannel.id, 'claude', err);
|
|
571
|
+
}
|
|
572
|
+
if (res && !res.headersSent) {
|
|
573
|
+
res.status(502).json({
|
|
574
|
+
error: 'Proxy error: ' + err.message,
|
|
575
|
+
type: 'proxy_error'
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
proxyServer = http.createServer(proxyApp);
|
|
581
|
+
|
|
582
|
+
return new Promise((resolve, reject) => {
|
|
583
|
+
proxyServer.listen(port, '127.0.0.1', () => {
|
|
584
|
+
console.log(`✅ Proxy server started on http://127.0.0.1:${port}`);
|
|
585
|
+
saveProxyStartTime('claude', preserveStartTime);
|
|
586
|
+
eventBus.emitSync('proxy:start', { channel: 'claude', port });
|
|
587
|
+
resolve({ success: true, port });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
proxyServer.on('error', (err) => {
|
|
591
|
+
if (err.code === 'EADDRINUSE') {
|
|
592
|
+
console.error(chalk.red(`\n❌ 代理服务端口 ${port} 已被占用`));
|
|
593
|
+
console.error(chalk.yellow('\n💡 解决方案:'));
|
|
594
|
+
console.error(chalk.gray(' 1. 运行 ctx 命令,选择"配置端口"修改端口'));
|
|
595
|
+
console.error(chalk.gray(` 2. 或关闭占用端口 ${port} 的程序\n`));
|
|
596
|
+
} else {
|
|
597
|
+
console.error('Failed to start proxy server:', err);
|
|
598
|
+
}
|
|
599
|
+
proxyServer = null;
|
|
600
|
+
proxyApp = null;
|
|
601
|
+
currentPort = null;
|
|
602
|
+
reject(err);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
} catch (err) {
|
|
606
|
+
console.error('Error starting proxy server:', err);
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function stopProxyServer(options = {}) {
|
|
612
|
+
const clearStartTime = options.clearStartTime !== false;
|
|
613
|
+
|
|
614
|
+
if (!proxyServer) {
|
|
615
|
+
return { success: true, message: 'Proxy server not running' };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
requestMetadata.clear();
|
|
619
|
+
|
|
620
|
+
return new Promise((resolve) => {
|
|
621
|
+
proxyServer.close(() => {
|
|
622
|
+
console.log('✅ Proxy server stopped');
|
|
623
|
+
if (clearStartTime) {
|
|
624
|
+
clearProxyStartTime('claude');
|
|
625
|
+
}
|
|
626
|
+
eventBus.emitSync('proxy:stop', { channel: 'claude' });
|
|
627
|
+
proxyServer = null;
|
|
628
|
+
proxyApp = null;
|
|
629
|
+
const stoppedPort = currentPort;
|
|
630
|
+
currentPort = null;
|
|
631
|
+
resolve({ success: true, port: stoppedPort });
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 获取代理服务器状态
|
|
637
|
+
function getProxyStatus() {
|
|
638
|
+
const config = loadConfig();
|
|
639
|
+
const startTime = getProxyStartTime('claude');
|
|
640
|
+
const runtime = getProxyRuntime('claude');
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
running: !!proxyServer,
|
|
644
|
+
port: currentPort,
|
|
645
|
+
defaultPort: config.ports?.proxy || 20088,
|
|
646
|
+
startTime,
|
|
647
|
+
runtime
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 清除指定渠道的模型重定向日志缓存
|
|
653
|
+
* 用于在渠道配置更新后触发重新打印日志
|
|
654
|
+
* @param {string} channelId - 渠道 ID
|
|
655
|
+
*/
|
|
656
|
+
function clearRedirectCache(channelId) {
|
|
657
|
+
if (channelId) {
|
|
658
|
+
printedRedirectCache.delete(channelId);
|
|
659
|
+
} else {
|
|
660
|
+
printedRedirectCache.clear();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
module.exports = {
|
|
665
|
+
startProxyServer,
|
|
666
|
+
stopProxyServer,
|
|
667
|
+
getProxyStatus,
|
|
668
|
+
clearRedirectCache
|
|
669
|
+
};
|