coding-tool-x 3.4.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{Analytics-DEjfL5Jx.js → Analytics-CbGxotgz.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-DkRL_-tf.js → ConfigTemplates-oP6nrFEb.js} +1 -1
- package/dist/web/assets/{Home-CF-L640I.js → Home-DMntmEvh.js} +1 -1
- package/dist/web/assets/{PluginManager-BzNYTdNB.js → PluginManager-BUC_c7nH.js} +1 -1
- package/dist/web/assets/{ProjectList-C0-JgHMM.js → ProjectList-CW8J49n7.js} +1 -1
- package/dist/web/assets/{SessionList-CkZUdX5N.js → SessionList-7lYnF92v.js} +1 -1
- package/dist/web/assets/{SkillManager-Cak0-4d4.js → SkillManager-Cs08216i.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-CGDJzwEr.js → WorkspaceManager-CY-oGtyB.js} +1 -1
- package/dist/web/assets/{index-Dz7v9OM0.css → index-5qy5NMIP.css} +1 -1
- package/dist/web/assets/index-ClCqKpvX.js +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/src/server/codex-proxy-server.js +4 -92
- package/src/server/gemini-proxy-server.js +5 -28
- package/src/server/opencode-proxy-server.js +3 -93
- package/src/server/proxy-server.js +2 -57
- package/src/server/services/base/base-channel-service.js +247 -0
- package/src/server/services/base/proxy-utils.js +152 -0
- package/src/server/services/channel-health.js +30 -19
- package/src/server/services/channels.js +125 -293
- package/src/server/services/codex-channels.js +148 -513
- package/src/server/services/codex-env-manager.js +49 -19
- package/src/server/services/gemini-channels.js +2 -7
- package/src/server/services/oauth-credentials-service.js +12 -2
- package/src/server/services/opencode-channels.js +7 -9
- package/src/server/services/repo-scanner-base.js +1 -0
- package/dist/web/assets/index-D_WItvHE.js +0 -2
package/dist/web/index.html
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
<link rel="icon" href="/favicon.ico">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
7
|
<title>CC-TOOL - ClaudeCode增强工作助手</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-ClCqKpvX.js"></script>
|
|
9
9
|
<link rel="modulepreload" crossorigin href="/assets/markdown-DyTJGI4N.js">
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-3bf-fPGP.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/vendors-CKPV1OAU.js">
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/naive-ui-Bdxp09n2.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="/assets/icons-B5Pl4lrD.js">
|
|
14
14
|
<link rel="stylesheet" crossorigin href="/assets/markdown-BfC0goYb.css">
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-5qy5NMIP.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="app"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coding-tool-x",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"description": "Vibe Coding 增强工作助手 - 智能会话管理、动态渠道切换、全局搜索、实时监控",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"test:codex-agents": "node scripts/test-codex-agents.js",
|
|
15
15
|
"test:skills": "node scripts/test-skill-providers.js",
|
|
16
16
|
"test:plugins-market": "node scripts/test-plugin-market-cache.js",
|
|
17
|
+
"test:unit": "vitest run",
|
|
18
|
+
"test:unit:watch": "vitest",
|
|
17
19
|
"test:windows": "node scripts/test-windows-regression.js",
|
|
18
20
|
"benchmark:codex": "node scripts/benchmark-codex-loading.js",
|
|
19
21
|
"build:web": "cd src/web && npm run build",
|
|
@@ -77,7 +79,9 @@
|
|
|
77
79
|
"systeminformation": "^5.31.4"
|
|
78
80
|
},
|
|
79
81
|
"devDependencies": {
|
|
80
|
-
"
|
|
82
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
83
|
+
"nodemon": "^3.0.2",
|
|
84
|
+
"vitest": "^4.1.0"
|
|
81
85
|
},
|
|
82
86
|
"repository": {
|
|
83
87
|
"type": "git",
|
|
@@ -14,6 +14,7 @@ const { createDecodedStream } = require('./services/response-decoder');
|
|
|
14
14
|
const { getEffectiveApiKey } = require('./services/codex-channels');
|
|
15
15
|
const { persistProxyRequestSnapshot } = require('./services/request-logger');
|
|
16
16
|
const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
|
|
17
|
+
const { redirectModel, resolveTargetUrl } = require('./services/base/proxy-utils');
|
|
17
18
|
|
|
18
19
|
let proxyServer = null;
|
|
19
20
|
let proxyApp = null;
|
|
@@ -46,99 +47,10 @@ const PRICING = {
|
|
|
46
47
|
const CODEX_BASE_PRICING = DEFAULT_CONFIG.pricing.codex;
|
|
47
48
|
const ONE_MILLION = 1000000;
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
* 检测模型层级
|
|
51
|
-
* @param {string} modelName - 模型名称
|
|
52
|
-
* @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
|
|
53
|
-
*/
|
|
54
|
-
function detectModelTier(modelName) {
|
|
55
|
-
if (!modelName) return null;
|
|
56
|
-
const lower = modelName.toLowerCase();
|
|
57
|
-
if (lower.includes('opus')) return 'opus';
|
|
58
|
-
if (lower.includes('sonnet')) return 'sonnet';
|
|
59
|
-
if (lower.includes('haiku')) return 'haiku';
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 应用模型重定向
|
|
65
|
-
* @param {string} originalModel - 原始模型名称
|
|
66
|
-
* @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
|
|
67
|
-
* @returns {string} 重定向后的模型名称
|
|
68
|
-
*/
|
|
69
|
-
function redirectModel(originalModel, channel) {
|
|
70
|
-
if (!originalModel) return originalModel;
|
|
71
|
-
|
|
72
|
-
// 优先使用新的 modelRedirects 数组格式
|
|
73
|
-
const modelRedirects = channel?.modelRedirects;
|
|
74
|
-
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
75
|
-
for (const rule of modelRedirects) {
|
|
76
|
-
if (rule.from && rule.to && rule.from === originalModel) {
|
|
77
|
-
return rule.to;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// 向后兼容:使用旧的 modelConfig 格式
|
|
83
|
-
const modelConfig = channel?.modelConfig;
|
|
84
|
-
if (!modelConfig) return originalModel;
|
|
85
|
-
|
|
86
|
-
const tier = detectModelTier(originalModel);
|
|
50
|
+
// detectModelTier, redirectModel, resolveTargetUrl imported from services/base/proxy-utils
|
|
87
51
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return modelConfig.opusModel;
|
|
91
|
-
}
|
|
92
|
-
if (tier === 'sonnet' && modelConfig.sonnetModel) {
|
|
93
|
-
return modelConfig.sonnetModel;
|
|
94
|
-
}
|
|
95
|
-
if (tier === 'haiku' && modelConfig.haikuModel) {
|
|
96
|
-
return modelConfig.haikuModel;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 回退到通用模型覆盖
|
|
100
|
-
if (modelConfig.model) {
|
|
101
|
-
return modelConfig.model;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return originalModel;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* 解析 Codex 代理目标 URL
|
|
109
|
-
*
|
|
110
|
-
* Codex CLI 发送请求到我们的代理时,请求路径格式:
|
|
111
|
-
* - /v1/responses (OpenAI Responses API)
|
|
112
|
-
* - /v1/chat/completions (OpenAI Chat Completions API)
|
|
113
|
-
*
|
|
114
|
-
* 渠道配置的 base_url 可能是:
|
|
115
|
-
* - https://api.openai.com/v1
|
|
116
|
-
* - https://example.com/openai/v1
|
|
117
|
-
* - https://example.com
|
|
118
|
-
*
|
|
119
|
-
* 最终转发目标示例:
|
|
120
|
-
* - base_url: https://example.com/openai/v1, path: /v1/responses
|
|
121
|
-
* -> target: https://example.com/openai, 最终: https://example.com/openai/v1/responses
|
|
122
|
-
*
|
|
123
|
-
* 这个函数返回要传给 http-proxy 的 target,http-proxy 会自动拼接 req.url
|
|
124
|
-
*/
|
|
125
|
-
function resolveCodexTarget(baseUrl = '', requestPath = '') {
|
|
126
|
-
let target = baseUrl || '';
|
|
127
|
-
|
|
128
|
-
// 移除末尾斜杠
|
|
129
|
-
if (target.endsWith('/')) {
|
|
130
|
-
target = target.slice(0, -1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 核心逻辑:避免 /v1/v1 重复
|
|
134
|
-
// 如果 base_url 以 /v1 结尾,且请求路径以 /v1 开头,去掉 base_url 的 /v1
|
|
135
|
-
// 因为 http-proxy 会将 requestPath 追加到 target 后面
|
|
136
|
-
if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
|
|
137
|
-
target = target.slice(0, -3);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return target;
|
|
141
|
-
}
|
|
52
|
+
// resolveCodexTarget replaced by resolveTargetUrl from proxy-utils
|
|
53
|
+
const resolveCodexTarget = resolveTargetUrl;
|
|
142
54
|
|
|
143
55
|
/**
|
|
144
56
|
* 计算请求成本
|
|
@@ -14,6 +14,7 @@ const { createDecodedStream } = require('./services/response-decoder');
|
|
|
14
14
|
const { getEffectiveApiKey } = require('./services/gemini-channels');
|
|
15
15
|
const { persistProxyRequestSnapshot } = require('./services/request-logger');
|
|
16
16
|
const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
|
|
17
|
+
const { redirectModel: redirectModelBase, resolveTargetUrl } = require('./services/base/proxy-utils');
|
|
17
18
|
|
|
18
19
|
let proxyServer = null;
|
|
19
20
|
let proxyApp = null;
|
|
@@ -45,36 +46,12 @@ const PRICING = {
|
|
|
45
46
|
const GEMINI_BASE_PRICING = DEFAULT_CONFIG.pricing.gemini;
|
|
46
47
|
const ONE_MILLION = 1000000;
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (target.endsWith('/')) {
|
|
51
|
-
target = target.slice(0, -1);
|
|
52
|
-
}
|
|
53
|
-
if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
|
|
54
|
-
target = target.slice(0, -3);
|
|
55
|
-
}
|
|
56
|
-
return target;
|
|
57
|
-
}
|
|
49
|
+
// resolveGeminiTarget replaced by resolveTargetUrl from proxy-utils
|
|
50
|
+
const resolveGeminiTarget = resolveTargetUrl;
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
* 应用模型重定向(精确匹配)
|
|
61
|
-
* @param {string} originalModel - 原始模型名称
|
|
62
|
-
* @param {object} channel - 渠道对象,包含 modelRedirects 数组
|
|
63
|
-
* @returns {string} 重定向后的模型名称
|
|
64
|
-
*/
|
|
52
|
+
// Gemini uses exact-match only redirect (no tier fallback)
|
|
65
53
|
function redirectModel(originalModel, channel) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const modelRedirects = channel?.modelRedirects;
|
|
69
|
-
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
70
|
-
for (const rule of modelRedirects) {
|
|
71
|
-
if (rule.from && rule.to && rule.from === originalModel) {
|
|
72
|
-
return rule.to;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return originalModel;
|
|
54
|
+
return redirectModelBase(originalModel, channel, { useTierFallback: false });
|
|
78
55
|
}
|
|
79
56
|
|
|
80
57
|
/**
|
|
@@ -21,6 +21,7 @@ const { getEnabledChannels, getEffectiveApiKey } = require('./services/opencode-
|
|
|
21
21
|
const { persistProxyRequestSnapshot, loadClaudeRequestTemplate } = require('./services/request-logger');
|
|
22
22
|
const { probeModelAvailability, fetchModelsFromProvider } = require('./services/model-detector');
|
|
23
23
|
const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
|
|
24
|
+
const { redirectModel, resolveTargetUrl } = require('./services/base/proxy-utils');
|
|
24
25
|
|
|
25
26
|
let proxyServer = null;
|
|
26
27
|
let proxyApp = null;
|
|
@@ -92,99 +93,8 @@ let cachedClaudeUserId = '';
|
|
|
92
93
|
let cachedClaudeRequestTemplate = null;
|
|
93
94
|
let cachedClaudeRequestTemplateAt = 0;
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* @param {string} modelName - 模型名称
|
|
98
|
-
* @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
|
|
99
|
-
*/
|
|
100
|
-
function detectModelTier(modelName) {
|
|
101
|
-
if (!modelName) return null;
|
|
102
|
-
const lower = modelName.toLowerCase();
|
|
103
|
-
if (lower.includes('opus')) return 'opus';
|
|
104
|
-
if (lower.includes('sonnet')) return 'sonnet';
|
|
105
|
-
if (lower.includes('haiku')) return 'haiku';
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* 应用模型重定向
|
|
111
|
-
* @param {string} originalModel - 原始模型名称
|
|
112
|
-
* @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
|
|
113
|
-
* @returns {string} 重定向后的模型名称
|
|
114
|
-
*/
|
|
115
|
-
function redirectModel(originalModel, channel) {
|
|
116
|
-
if (!originalModel) return originalModel;
|
|
117
|
-
|
|
118
|
-
// 优先使用新的 modelRedirects 数组格式
|
|
119
|
-
const modelRedirects = channel?.modelRedirects;
|
|
120
|
-
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
121
|
-
for (const rule of modelRedirects) {
|
|
122
|
-
if (rule.from && rule.to && rule.from === originalModel) {
|
|
123
|
-
return rule.to;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// 向后兼容:使用旧的 modelConfig 格式
|
|
129
|
-
const modelConfig = channel?.modelConfig;
|
|
130
|
-
if (!modelConfig) return originalModel;
|
|
131
|
-
|
|
132
|
-
const tier = detectModelTier(originalModel);
|
|
133
|
-
|
|
134
|
-
// 优先级:层级特定配置 > 通用模型覆盖
|
|
135
|
-
if (tier === 'opus' && modelConfig.opusModel) {
|
|
136
|
-
return modelConfig.opusModel;
|
|
137
|
-
}
|
|
138
|
-
if (tier === 'sonnet' && modelConfig.sonnetModel) {
|
|
139
|
-
return modelConfig.sonnetModel;
|
|
140
|
-
}
|
|
141
|
-
if (tier === 'haiku' && modelConfig.haikuModel) {
|
|
142
|
-
return modelConfig.haikuModel;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// 回退到通用模型覆盖
|
|
146
|
-
if (modelConfig.model) {
|
|
147
|
-
return modelConfig.model;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return originalModel;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* 解析 OpenCode 代理目标 URL
|
|
155
|
-
*
|
|
156
|
-
* OpenCode CLI 发送请求到我们的代理时,请求路径格式:
|
|
157
|
-
* - /v1/responses (OpenAI Responses API)
|
|
158
|
-
* - /v1/chat/completions (OpenAI Chat Completions API)
|
|
159
|
-
*
|
|
160
|
-
* 渠道配置的 base_url 可能是:
|
|
161
|
-
* - https://api.openai.com/v1
|
|
162
|
-
* - https://example.com/openai/v1
|
|
163
|
-
* - https://example.com
|
|
164
|
-
*
|
|
165
|
-
* 最终转发目标示例:
|
|
166
|
-
* - base_url: https://example.com/openai/v1, path: /v1/responses
|
|
167
|
-
* -> target: https://example.com/openai, 最终: https://example.com/openai/v1/responses
|
|
168
|
-
*
|
|
169
|
-
* 这个函数返回要传给 http-proxy 的 target,http-proxy 会自动拼接 req.url
|
|
170
|
-
*/
|
|
171
|
-
function resolveOpenCodeTarget(baseUrl = '', requestPath = '') {
|
|
172
|
-
let target = baseUrl || '';
|
|
173
|
-
|
|
174
|
-
// 移除末尾斜杠
|
|
175
|
-
if (target.endsWith('/')) {
|
|
176
|
-
target = target.slice(0, -1);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 核心逻辑:避免 /v1/v1 重复
|
|
180
|
-
// 如果 base_url 以 /v1 结尾,且请求路径以 /v1 开头,去掉 base_url 的 /v1
|
|
181
|
-
// 因为 http-proxy 会将 requestPath 追加到 target 后面
|
|
182
|
-
if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
|
|
183
|
-
target = target.slice(0, -3);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return target;
|
|
187
|
-
}
|
|
96
|
+
// detectModelTier, redirectModel, resolveTargetUrl imported from services/base/proxy-utils
|
|
97
|
+
const resolveOpenCodeTarget = resolveTargetUrl;
|
|
188
98
|
|
|
189
99
|
/**
|
|
190
100
|
* 计算请求成本
|
|
@@ -19,6 +19,7 @@ const eventBus = require('../plugins/event-bus');
|
|
|
19
19
|
const { getEffectiveApiKey } = require('./services/channels');
|
|
20
20
|
const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('./services/request-logger');
|
|
21
21
|
const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
|
|
22
|
+
const { redirectModel } = require('./services/base/proxy-utils');
|
|
22
23
|
|
|
23
24
|
let proxyServer = null;
|
|
24
25
|
let proxyApp = null;
|
|
@@ -34,63 +35,7 @@ const printedRedirectCache = new Map();
|
|
|
34
35
|
const CLAUDE_BASE_PRICING = DEFAULT_CONFIG.pricing.claude;
|
|
35
36
|
const ONE_MILLION = 1000000;
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
* 检测模型层级
|
|
39
|
-
* @param {string} modelName - 模型名称
|
|
40
|
-
* @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
|
|
41
|
-
*/
|
|
42
|
-
function detectModelTier(modelName) {
|
|
43
|
-
if (!modelName) return null;
|
|
44
|
-
const lower = modelName.toLowerCase();
|
|
45
|
-
if (lower.includes('opus')) return 'opus';
|
|
46
|
-
if (lower.includes('sonnet')) return 'sonnet';
|
|
47
|
-
if (lower.includes('haiku')) return 'haiku';
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* 应用模型重定向
|
|
53
|
-
* @param {string} originalModel - 原始模型名称
|
|
54
|
-
* @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
|
|
55
|
-
* @returns {string} 重定向后的模型名称
|
|
56
|
-
*/
|
|
57
|
-
function redirectModel(originalModel, channel) {
|
|
58
|
-
if (!originalModel) return originalModel;
|
|
59
|
-
|
|
60
|
-
// 优先使用新的 modelRedirects 数组格式
|
|
61
|
-
const modelRedirects = channel?.modelRedirects;
|
|
62
|
-
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
63
|
-
for (const rule of modelRedirects) {
|
|
64
|
-
if (rule.from && rule.to && rule.from === originalModel) {
|
|
65
|
-
return rule.to;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 向后兼容:使用旧的 modelConfig 格式
|
|
71
|
-
const modelConfig = channel?.modelConfig;
|
|
72
|
-
if (!modelConfig) return originalModel;
|
|
73
|
-
|
|
74
|
-
const tier = detectModelTier(originalModel);
|
|
75
|
-
|
|
76
|
-
// 优先级:层级特定配置 > 通用模型覆盖
|
|
77
|
-
if (tier === 'opus' && modelConfig.opusModel) {
|
|
78
|
-
return modelConfig.opusModel;
|
|
79
|
-
}
|
|
80
|
-
if (tier === 'sonnet' && modelConfig.sonnetModel) {
|
|
81
|
-
return modelConfig.sonnetModel;
|
|
82
|
-
}
|
|
83
|
-
if (tier === 'haiku' && modelConfig.haikuModel) {
|
|
84
|
-
return modelConfig.haikuModel;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 回退到通用模型覆盖
|
|
88
|
-
if (modelConfig.model) {
|
|
89
|
-
return modelConfig.model;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return originalModel;
|
|
93
|
-
}
|
|
38
|
+
// detectModelTier and redirectModel imported from services/base/proxy-utils
|
|
94
39
|
|
|
95
40
|
/**
|
|
96
41
|
* 计算请求成本
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseChannelService - 四平台渠道管理的公共基类
|
|
3
|
+
*
|
|
4
|
+
* 提取 channels.js / codex-channels.js / gemini-channels.js / opencode-channels.js
|
|
5
|
+
* 中的共享 CRUD、启用/禁用、单渠道强制等逻辑。
|
|
6
|
+
*
|
|
7
|
+
* 子类通过覆写钩子方法实现平台差异化行为。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { normalizeGatewaySourceType, normalizeNumber } = require('./proxy-utils');
|
|
14
|
+
|
|
15
|
+
class BaseChannelService {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} config
|
|
18
|
+
* @param {string} config.platform - 'claude'|'codex'|'gemini'|'opencode'
|
|
19
|
+
* @param {string} config.channelsFilePath - 渠道数据文件路径
|
|
20
|
+
* @param {string} [config.defaultGatewaySource] - 默认网关来源类型
|
|
21
|
+
* @param {Function} [config.isProxyRunning] - 返回代理是否运行中
|
|
22
|
+
*/
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.platform = config.platform;
|
|
25
|
+
this.channelsFilePath = config.channelsFilePath;
|
|
26
|
+
this.defaultGatewaySource = config.defaultGatewaySource || config.platform;
|
|
27
|
+
this._isProxyRunning = config.isProxyRunning || (() => false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 文件 I/O ──
|
|
31
|
+
|
|
32
|
+
_ensureDir() {
|
|
33
|
+
const dir = path.dirname(this.channelsFilePath);
|
|
34
|
+
if (!fs.existsSync(dir)) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
loadChannels() {
|
|
40
|
+
this._ensureDir();
|
|
41
|
+
try {
|
|
42
|
+
if (fs.existsSync(this.channelsFilePath)) {
|
|
43
|
+
const raw = JSON.parse(fs.readFileSync(this.channelsFilePath, 'utf8'));
|
|
44
|
+
const channels = Array.isArray(raw?.channels) ? raw.channels : [];
|
|
45
|
+
return { channels: channels.map(ch => this._applyDefaults(ch)) };
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`[${this.platform}-channels] Error loading channels:`, err.message);
|
|
49
|
+
}
|
|
50
|
+
return { channels: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
saveChannels(data) {
|
|
54
|
+
this._ensureDir();
|
|
55
|
+
fs.writeFileSync(this.channelsFilePath, JSON.stringify(data, null, 2), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── 查询 ──
|
|
59
|
+
|
|
60
|
+
getChannels() {
|
|
61
|
+
return this.loadChannels();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getEnabledChannels() {
|
|
65
|
+
const data = this.loadChannels();
|
|
66
|
+
return data.channels.filter(ch => ch.enabled !== false);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── CRUD ──
|
|
70
|
+
|
|
71
|
+
createChannel(fields) {
|
|
72
|
+
const data = this.loadChannels();
|
|
73
|
+
|
|
74
|
+
// 子类可覆写的唯一性校验
|
|
75
|
+
this._validateUniqueness(data.channels, fields);
|
|
76
|
+
|
|
77
|
+
const channel = this._applyDefaults({
|
|
78
|
+
id: this._generateId(),
|
|
79
|
+
...fields,
|
|
80
|
+
createdAt: Date.now(),
|
|
81
|
+
updatedAt: Date.now(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
data.channels.push(channel);
|
|
85
|
+
|
|
86
|
+
// 单渠道强制:代理未运行时只允许一个渠道启用
|
|
87
|
+
if (channel.enabled && !this._isProxyRunning()) {
|
|
88
|
+
this._enforceSingleChannel(data.channels, data.channels.length - 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.saveChannels(data);
|
|
92
|
+
this._onAfterCreate(channel, data.channels);
|
|
93
|
+
return channel;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateChannel(channelId, updates) {
|
|
97
|
+
const data = this.loadChannels();
|
|
98
|
+
const index = data.channels.findIndex(ch => ch.id === channelId);
|
|
99
|
+
if (index === -1) {
|
|
100
|
+
throw new Error('Channel not found');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const oldChannel = data.channels[index];
|
|
104
|
+
|
|
105
|
+
// 子类可覆写的唯一性校验(排除自身)
|
|
106
|
+
this._validateUniqueness(data.channels, updates, channelId);
|
|
107
|
+
|
|
108
|
+
const nextChannel = this._applyDefaults({
|
|
109
|
+
...oldChannel,
|
|
110
|
+
...updates,
|
|
111
|
+
id: channelId,
|
|
112
|
+
updatedAt: Date.now(),
|
|
113
|
+
});
|
|
114
|
+
data.channels[index] = nextChannel;
|
|
115
|
+
|
|
116
|
+
// 单渠道强制
|
|
117
|
+
const isProxyRunning = this._isProxyRunning();
|
|
118
|
+
if (!isProxyRunning && nextChannel.enabled && !oldChannel.enabled) {
|
|
119
|
+
this._enforceSingleChannel(data.channels, index);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.saveChannels(data);
|
|
123
|
+
this._onAfterUpdate(oldChannel, nextChannel, data.channels);
|
|
124
|
+
return nextChannel;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
deleteChannel(channelId) {
|
|
128
|
+
const data = this.loadChannels();
|
|
129
|
+
const index = data.channels.findIndex(ch => ch.id === channelId);
|
|
130
|
+
if (index === -1) {
|
|
131
|
+
throw new Error('Channel not found');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const removed = data.channels.splice(index, 1)[0];
|
|
135
|
+
this.saveChannels(data);
|
|
136
|
+
this._onAfterDelete(removed, data.channels);
|
|
137
|
+
return { success: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 启用/禁用 ──
|
|
141
|
+
|
|
142
|
+
disableAllChannels() {
|
|
143
|
+
const data = this.loadChannels();
|
|
144
|
+
data.channels.forEach(ch => { ch.enabled = false; });
|
|
145
|
+
this.saveChannels(data);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
applyChannelToSettings(channelId) {
|
|
149
|
+
const data = this.loadChannels();
|
|
150
|
+
const channel = data.channels.find(ch => ch.id === channelId);
|
|
151
|
+
if (!channel) {
|
|
152
|
+
throw new Error('Channel not found');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 单渠道模式:只启用目标渠道
|
|
156
|
+
data.channels.forEach(ch => {
|
|
157
|
+
ch.enabled = ch.id === channelId;
|
|
158
|
+
});
|
|
159
|
+
this.saveChannels(data);
|
|
160
|
+
this._applyToNativeSettings(channel);
|
|
161
|
+
return channel;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── 排序 ──
|
|
165
|
+
|
|
166
|
+
saveChannelOrder(order) {
|
|
167
|
+
if (!Array.isArray(order)) return;
|
|
168
|
+
const data = this.loadChannels();
|
|
169
|
+
const channelMap = new Map(data.channels.map(ch => [ch.id, ch]));
|
|
170
|
+
const ordered = [];
|
|
171
|
+
for (const id of order) {
|
|
172
|
+
const ch = channelMap.get(id);
|
|
173
|
+
if (ch) {
|
|
174
|
+
ordered.push(ch);
|
|
175
|
+
channelMap.delete(id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 未在 order 中的渠道追加到末尾
|
|
179
|
+
for (const ch of channelMap.values()) {
|
|
180
|
+
ordered.push(ch);
|
|
181
|
+
}
|
|
182
|
+
data.channels = ordered;
|
|
183
|
+
this.saveChannels(data);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── API Key ──
|
|
187
|
+
|
|
188
|
+
getEffectiveApiKey(channel) {
|
|
189
|
+
return channel?.apiKey || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 内部方法 ──
|
|
193
|
+
|
|
194
|
+
_generateId() {
|
|
195
|
+
return crypto.randomUUID();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_enforceSingleChannel(channels, enabledIndex) {
|
|
199
|
+
channels.forEach((ch, i) => {
|
|
200
|
+
if (i !== enabledIndex && ch.enabled) {
|
|
201
|
+
ch.enabled = false;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
const name = channels[enabledIndex]?.name || channels[enabledIndex]?.id;
|
|
205
|
+
console.log(`[${this.platform}] Single-channel mode: enabled "${name}", disabled all others`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── 子类钩子(默认空实现)──
|
|
209
|
+
|
|
210
|
+
/** 应用渠道默认值,子类覆写以添加平台特有字段 */
|
|
211
|
+
_applyDefaults(channel) {
|
|
212
|
+
const normalized = { ...channel };
|
|
213
|
+
if (normalized.enabled === undefined) {
|
|
214
|
+
normalized.enabled = true;
|
|
215
|
+
} else {
|
|
216
|
+
normalized.enabled = !!normalized.enabled;
|
|
217
|
+
}
|
|
218
|
+
normalized.weight = normalizeNumber(normalized.weight, 1, 100);
|
|
219
|
+
normalized.maxConcurrency = normalizeNumber(normalized.maxConcurrency, 0);
|
|
220
|
+
normalized.gatewaySourceType = normalizeGatewaySourceType(
|
|
221
|
+
normalized.gatewaySourceType,
|
|
222
|
+
this.defaultGatewaySource
|
|
223
|
+
);
|
|
224
|
+
return normalized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 唯一性校验,子类覆写(如 Codex 的 providerKey、Gemini 的 name) */
|
|
228
|
+
_validateUniqueness(_channels, _fields, _excludeId) {
|
|
229
|
+
// 默认无校验
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 创建后钩子 */
|
|
233
|
+
_onAfterCreate(_channel, _allChannels) {}
|
|
234
|
+
|
|
235
|
+
/** 更新后钩子 */
|
|
236
|
+
_onAfterUpdate(_oldChannel, _newChannel, _allChannels) {}
|
|
237
|
+
|
|
238
|
+
/** 删除后钩子 */
|
|
239
|
+
_onAfterDelete(_channel, _allChannels) {}
|
|
240
|
+
|
|
241
|
+
/** 将渠道配置写入平台原生设置文件,子类必须覆写 */
|
|
242
|
+
_applyToNativeSettings(_channel) {
|
|
243
|
+
throw new Error(`${this.platform}: _applyToNativeSettings not implemented`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = BaseChannelService;
|