coding-tool-x 3.4.0 → 3.4.2
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/commands/doctor.js +2 -2
- package/src/commands/resume.js +1 -0
- package/src/commands/update.js +2 -1
- package/src/plugins/plugin-installer.js +1 -0
- package/src/server/api/claude-hooks.js +2 -3
- package/src/server/api/workspaces.js +2 -1
- 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 +81 -21
- package/src/server/services/codex-settings-manager.js +20 -5
- package/src/server/services/gemini-channels.js +2 -7
- package/src/server/services/mcp-client.js +2 -1
- package/src/server/services/notification-hooks.js +9 -8
- package/src/server/services/oauth-credentials-service.js +12 -2
- package/src/server/services/opencode-channels.js +7 -9
- package/src/server/services/opencode-sessions.js +4 -2
- package/src/server/services/plugins-service.js +2 -1
- package/src/server/services/repo-scanner-base.js +1 -0
- package/src/server/services/skill-service.js +4 -2
- package/src/server/services/workspace-service.js +1 -0
- package/src/utils/port-helper.js +5 -5
- package/dist/web/assets/index-D_WItvHE.js +0 -2
|
@@ -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;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy-utils.js - 代理服务器共享工具函数
|
|
3
|
+
*
|
|
4
|
+
* 从四个 proxy-server 中提取的公共逻辑,消除重复代码。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 检测模型层级(Claude 系列)
|
|
9
|
+
* @param {string} modelName
|
|
10
|
+
* @returns {'opus'|'sonnet'|'haiku'|null}
|
|
11
|
+
*/
|
|
12
|
+
function detectModelTier(modelName) {
|
|
13
|
+
if (!modelName) return null;
|
|
14
|
+
const lower = modelName.toLowerCase();
|
|
15
|
+
if (lower.includes('opus')) return 'opus';
|
|
16
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
17
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 应用模型重定向
|
|
23
|
+
*
|
|
24
|
+
* 支持两种格式:
|
|
25
|
+
* 1. modelRedirects 数组(新格式,精确匹配)
|
|
26
|
+
* 2. modelConfig 对象(旧格式,层级匹配 + 通用覆盖)
|
|
27
|
+
*
|
|
28
|
+
* @param {string} originalModel
|
|
29
|
+
* @param {object} channel - 渠道对象
|
|
30
|
+
* @param {object} [options]
|
|
31
|
+
* @param {boolean} [options.useTierFallback=true] - 是否启用层级回退(Gemini 不用)
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function redirectModel(originalModel, channel, options = {}) {
|
|
35
|
+
if (!originalModel) return originalModel;
|
|
36
|
+
|
|
37
|
+
// 优先使用 modelRedirects 数组格式(精确匹配)
|
|
38
|
+
const modelRedirects = channel?.modelRedirects;
|
|
39
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
40
|
+
for (const rule of modelRedirects) {
|
|
41
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
42
|
+
return rule.to;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 如果不启用层级回退,到此为止
|
|
48
|
+
const useTierFallback = options.useTierFallback !== false;
|
|
49
|
+
if (!useTierFallback) {
|
|
50
|
+
return originalModel;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 向后兼容:使用旧的 modelConfig 格式
|
|
54
|
+
const modelConfig = channel?.modelConfig;
|
|
55
|
+
if (!modelConfig) return originalModel;
|
|
56
|
+
|
|
57
|
+
const tier = detectModelTier(originalModel);
|
|
58
|
+
|
|
59
|
+
if (tier === 'opus' && modelConfig.opusModel) return modelConfig.opusModel;
|
|
60
|
+
if (tier === 'sonnet' && modelConfig.sonnetModel) return modelConfig.sonnetModel;
|
|
61
|
+
if (tier === 'haiku' && modelConfig.haikuModel) return modelConfig.haikuModel;
|
|
62
|
+
|
|
63
|
+
// 回退到通用模型覆盖
|
|
64
|
+
if (modelConfig.model) return modelConfig.model;
|
|
65
|
+
|
|
66
|
+
return originalModel;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析代理目标 URL,避免 /v1/v1 重复
|
|
71
|
+
*
|
|
72
|
+
* 当 baseUrl 以 /v1 结尾且请求路径也以 /v1 开头时,
|
|
73
|
+
* 去掉 baseUrl 的 /v1 后缀,因为 http-proxy 会自动拼接 req.url。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} baseUrl - 渠道配置的 base_url
|
|
76
|
+
* @param {string} requestPath - 请求路径
|
|
77
|
+
* @returns {string} 传给 http-proxy 的 target
|
|
78
|
+
*/
|
|
79
|
+
function resolveTargetUrl(baseUrl, requestPath) {
|
|
80
|
+
let target = baseUrl || '';
|
|
81
|
+
if (target.endsWith('/')) {
|
|
82
|
+
target = target.slice(0, -1);
|
|
83
|
+
}
|
|
84
|
+
if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
|
|
85
|
+
target = target.slice(0, -3);
|
|
86
|
+
}
|
|
87
|
+
return target;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 规范化网关来源类型
|
|
92
|
+
* @param {string} value
|
|
93
|
+
* @param {string} [fallback='claude']
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function normalizeGatewaySourceType(value, fallback = 'claude') {
|
|
97
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
98
|
+
if (normalized === 'claude') return 'claude';
|
|
99
|
+
if (normalized === 'codex') return 'codex';
|
|
100
|
+
if (normalized === 'gemini') return 'gemini';
|
|
101
|
+
if (normalized === 'openai_compatible') return 'openai_compatible';
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 规范化数值字段
|
|
107
|
+
* @param {*} value
|
|
108
|
+
* @param {number} defaultValue
|
|
109
|
+
* @param {number|null} [max=null]
|
|
110
|
+
* @returns {number}
|
|
111
|
+
*/
|
|
112
|
+
function normalizeNumber(value, defaultValue, max = null) {
|
|
113
|
+
const num = Number(value);
|
|
114
|
+
if (!Number.isFinite(num) || num <= 0) {
|
|
115
|
+
return defaultValue;
|
|
116
|
+
}
|
|
117
|
+
if (max !== null && num > max) {
|
|
118
|
+
return max;
|
|
119
|
+
}
|
|
120
|
+
return num;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 记录模型重定向日志(避免重复打印)
|
|
125
|
+
* @param {Map} cache - 重定向缓存 Map
|
|
126
|
+
* @param {string} channelId
|
|
127
|
+
* @param {string} originalModel
|
|
128
|
+
* @param {string} redirectedModel
|
|
129
|
+
* @param {string} channelName
|
|
130
|
+
* @param {string} source - 平台标识
|
|
131
|
+
*/
|
|
132
|
+
function logModelRedirect(cache, channelId, originalModel, redirectedModel, channelName, source) {
|
|
133
|
+
if (originalModel === redirectedModel) return;
|
|
134
|
+
|
|
135
|
+
if (!cache.has(channelId)) {
|
|
136
|
+
cache.set(channelId, {});
|
|
137
|
+
}
|
|
138
|
+
const channelCache = cache.get(channelId);
|
|
139
|
+
if (channelCache[originalModel] === redirectedModel) return;
|
|
140
|
+
|
|
141
|
+
channelCache[originalModel] = redirectedModel;
|
|
142
|
+
console.log(`[${source}-proxy] Model redirect: ${originalModel} → ${redirectedModel} (channel: ${channelName})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
detectModelTier,
|
|
147
|
+
redirectModel,
|
|
148
|
+
resolveTargetUrl,
|
|
149
|
+
normalizeGatewaySourceType,
|
|
150
|
+
normalizeNumber,
|
|
151
|
+
logModelRedirect,
|
|
152
|
+
};
|
|
@@ -51,6 +51,24 @@ function initChannelHealth(channelId, source = 'claude') {
|
|
|
51
51
|
return channelHealth.get(key);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function transitionFrozenChannelIfExpired(channelId, source = 'claude') {
|
|
55
|
+
const health = initChannelHealth(channelId, source);
|
|
56
|
+
if (health.status !== 'frozen') {
|
|
57
|
+
return health;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
if (now < health.freezeUntil) {
|
|
62
|
+
return health;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
health.status = 'checking';
|
|
66
|
+
health.consecutiveSuccesses = 0;
|
|
67
|
+
health.freezeUntil = 0;
|
|
68
|
+
console.log(`[ChannelHealth] Channel ${channelId} freeze expired, entering checking mode`);
|
|
69
|
+
return health;
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
/**
|
|
55
73
|
* 记录成功请求
|
|
56
74
|
*/
|
|
@@ -119,21 +137,13 @@ function isChannelAvailable(channelId, source = 'claude') {
|
|
|
119
137
|
const health = channelHealth.get(key);
|
|
120
138
|
if (!health) return true;
|
|
121
139
|
|
|
122
|
-
const
|
|
140
|
+
const currentHealth = transitionFrozenChannelIfExpired(channelId, source);
|
|
123
141
|
|
|
124
|
-
switch (
|
|
142
|
+
switch (currentHealth.status) {
|
|
125
143
|
case 'healthy':
|
|
126
144
|
return true;
|
|
127
145
|
|
|
128
146
|
case 'frozen':
|
|
129
|
-
// 检查冻结时间是否到期
|
|
130
|
-
if (now >= health.freezeUntil) {
|
|
131
|
-
// 进入检测状态
|
|
132
|
-
health.status = 'checking';
|
|
133
|
-
health.consecutiveSuccesses = 0;
|
|
134
|
-
console.log(`[ChannelHealth] Channel ${channelId} freeze expired, entering checking mode`);
|
|
135
|
-
return true; // 允许一个请求用于健康检测
|
|
136
|
-
}
|
|
137
147
|
return false;
|
|
138
148
|
|
|
139
149
|
case 'checking':
|
|
@@ -172,8 +182,9 @@ function getChannelHealthStatus(channelId, source = 'claude') {
|
|
|
172
182
|
};
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
const currentHealth = transitionFrozenChannelIfExpired(channelId, source);
|
|
175
186
|
const now = Date.now();
|
|
176
|
-
const freezeRemaining = Math.max(0,
|
|
187
|
+
const freezeRemaining = Math.max(0, currentHealth.freezeUntil - now);
|
|
177
188
|
|
|
178
189
|
const statusMap = {
|
|
179
190
|
'healthy': { text: '健康', color: '#18a058' },
|
|
@@ -182,14 +193,14 @@ function getChannelHealthStatus(channelId, source = 'claude') {
|
|
|
182
193
|
};
|
|
183
194
|
|
|
184
195
|
return {
|
|
185
|
-
status:
|
|
186
|
-
statusText: statusMap[
|
|
187
|
-
statusColor: statusMap[
|
|
188
|
-
consecutiveFailures:
|
|
189
|
-
consecutiveSuccesses:
|
|
190
|
-
totalFailures:
|
|
191
|
-
totalSuccesses:
|
|
192
|
-
freezeUntil:
|
|
196
|
+
status: currentHealth.status,
|
|
197
|
+
statusText: statusMap[currentHealth.status]?.text || '未知',
|
|
198
|
+
statusColor: statusMap[currentHealth.status]?.color || '#909399',
|
|
199
|
+
consecutiveFailures: currentHealth.consecutiveFailures,
|
|
200
|
+
consecutiveSuccesses: currentHealth.consecutiveSuccesses,
|
|
201
|
+
totalFailures: currentHealth.totalFailures,
|
|
202
|
+
totalSuccesses: currentHealth.totalSuccesses,
|
|
203
|
+
freezeUntil: currentHealth.freezeUntil,
|
|
193
204
|
freezeRemaining: Math.ceil(freezeRemaining / 1000), // 剩余秒数
|
|
194
205
|
};
|
|
195
206
|
}
|