yingclaw 2.5.20 → 2.5.25
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/README.md +27 -6
- package/bin/cli.js +201 -24
- package/index.js +2 -0
- package/lib/config.js +137 -24
- package/lib/doctor.js +13 -3
- package/lib/gateway.js +105 -6
- package/lib/openai.js +152 -0
- package/lib/panel.js +17 -5
- package/lib/vscode.js +256 -0
- package/package.json +2 -2
package/lib/config.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { spawnSync } = require('child_process');
|
|
5
|
+
const { normalizeOpenAiBaseUrl, openAiChatCompletionsUrl } = require('./openai');
|
|
5
6
|
|
|
6
7
|
const CONFIG_FILE = path.join(os.homedir(), '.clawai.json');
|
|
7
8
|
const WINDOWS_ENV_LABEL = 'Windows 用户环境变量';
|
|
@@ -15,10 +16,17 @@ const CLAUDE_ENV_KEYS = [
|
|
|
15
16
|
'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
16
17
|
'CLAUDE_CODE_EFFORT_LEVEL',
|
|
17
18
|
];
|
|
19
|
+
const VSCODE_CLAUDE_ENV_KEYS = [
|
|
20
|
+
'ANTHROPIC_CUSTOM_MODEL_OPTION',
|
|
21
|
+
'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME',
|
|
22
|
+
'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION',
|
|
23
|
+
'ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES',
|
|
24
|
+
'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY',
|
|
25
|
+
];
|
|
18
26
|
const LEGACY_CLAUDE_ENV_KEYS = [
|
|
19
27
|
'ANTHROPIC_API_KEY',
|
|
20
28
|
];
|
|
21
|
-
const CLEAR_CLAUDE_ENV_KEYS = [...new Set([...CLAUDE_ENV_KEYS, ...LEGACY_CLAUDE_ENV_KEYS])];
|
|
29
|
+
const CLEAR_CLAUDE_ENV_KEYS = [...new Set([...CLAUDE_ENV_KEYS, ...VSCODE_CLAUDE_ENV_KEYS, ...LEGACY_CLAUDE_ENV_KEYS])];
|
|
22
30
|
|
|
23
31
|
const PROVIDERS = {
|
|
24
32
|
deepseek: {
|
|
@@ -33,13 +41,27 @@ const PROVIDERS = {
|
|
|
33
41
|
},
|
|
34
42
|
kimi: {
|
|
35
43
|
name: 'Kimi / Moonshot',
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
protocol: 'openai',
|
|
45
|
+
baseUrl: 'https://api.moonshot.cn/v1',
|
|
46
|
+
modelsUrl: 'https://api.moonshot.cn/v1/models',
|
|
38
47
|
fastModel: 'kimi-k2.5',
|
|
39
48
|
models: [
|
|
40
49
|
{ name: 'Kimi K2.5(代码)', value: 'kimi-k2.5' },
|
|
41
50
|
],
|
|
42
51
|
},
|
|
52
|
+
volcengine: {
|
|
53
|
+
name: '火山方舟 Coding Plan',
|
|
54
|
+
baseUrl: 'https://ark.cn-beijing.volces.com/api/coding',
|
|
55
|
+
modelsUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3/models',
|
|
56
|
+
fastModel: 'ark-code-latest',
|
|
57
|
+
models: [
|
|
58
|
+
{ name: 'Ark Code Latest(控制台选择)', value: 'ark-code-latest' },
|
|
59
|
+
{ name: 'Doubao Seed Code', value: 'doubao-seed-code' },
|
|
60
|
+
{ name: 'GLM 4.7', value: 'glm-4.7' },
|
|
61
|
+
{ name: 'DeepSeek V3.2', value: 'deepseek-v3.2' },
|
|
62
|
+
{ name: 'Kimi K2.5', value: 'kimi-k2.5' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
43
65
|
qwen: {
|
|
44
66
|
name: '阿里云百炼 (Qwen)',
|
|
45
67
|
baseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic',
|
|
@@ -90,6 +112,13 @@ const PROVIDERS = {
|
|
|
90
112
|
custom: {
|
|
91
113
|
name: '自定义 Anthropic 兼容接口',
|
|
92
114
|
custom: true,
|
|
115
|
+
protocol: 'anthropic',
|
|
116
|
+
models: [],
|
|
117
|
+
},
|
|
118
|
+
custom_openai: {
|
|
119
|
+
name: '自定义 OpenAI 兼容接口',
|
|
120
|
+
custom: true,
|
|
121
|
+
protocol: 'openai',
|
|
93
122
|
models: [],
|
|
94
123
|
},
|
|
95
124
|
};
|
|
@@ -110,11 +139,28 @@ function sortPreferredModelIds(providerKey, ids) {
|
|
|
110
139
|
];
|
|
111
140
|
}
|
|
112
141
|
|
|
142
|
+
function isChatModelId(model) {
|
|
143
|
+
const value = String(model || '');
|
|
144
|
+
return !/(^|[-_])(tts|voice|voiceclone|voicedesign|speech|audio|image|video|embedding|embed|rerank|moderation)([-_]|$)/i.test(value)
|
|
145
|
+
&& !/(^|[-_])(i2v|t2v|t2i|i2i|v2v|video-generation|image-generation)([-_]|$)/i.test(value)
|
|
146
|
+
&& !/(cogview|cogvideo|wanx|^wan\d|seedance|seedream|seededit|vision|hyper3d|hitem3d|seed3d|flux|sdxl|stable-diffusion)/i.test(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
113
149
|
function normalizeModelIds(providerKey, ids) {
|
|
114
150
|
if (PREFERRED_MODEL_IDS[providerKey]) {
|
|
115
151
|
return sortPreferredModelIds(providerKey, ids);
|
|
116
152
|
}
|
|
117
153
|
|
|
154
|
+
if (providerKey === 'kimi') {
|
|
155
|
+
const preferred = PROVIDERS.kimi.models.map((item) => item.value);
|
|
156
|
+
const merged = [...new Set([...preferred, ...ids])];
|
|
157
|
+
return [
|
|
158
|
+
...preferred.filter((id) => merged.includes(id)),
|
|
159
|
+
...merged.filter((id) => !preferred.includes(id) && /^kimi-/i.test(id)),
|
|
160
|
+
...merged.filter((id) => !preferred.includes(id) && !/^kimi-/i.test(id)),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
118
164
|
if (providerKey !== 'deepseek') return ids;
|
|
119
165
|
|
|
120
166
|
const mapped = ids.map((id) => {
|
|
@@ -132,7 +178,13 @@ function normalizeModelIds(providerKey, ids) {
|
|
|
132
178
|
function parseModelIdsResponse(providerKey, data) {
|
|
133
179
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
134
180
|
const list = parsed.data || parsed.models || [];
|
|
135
|
-
const
|
|
181
|
+
const availableList = providerKey === 'volcengine'
|
|
182
|
+
? list.filter((model) => !/^shutdown$/i.test(String(model.status || '')))
|
|
183
|
+
: list;
|
|
184
|
+
const ids = availableList.map(m => m.id || m.model || m.name).filter(Boolean);
|
|
185
|
+
if (providerKey === 'volcengine') {
|
|
186
|
+
return normalizeModelIds(providerKey, ids.filter(isChatModelId));
|
|
187
|
+
}
|
|
136
188
|
return normalizeModelIds(providerKey, ids);
|
|
137
189
|
}
|
|
138
190
|
|
|
@@ -155,7 +207,7 @@ function normalizeAnthropicBaseUrl(baseUrl) {
|
|
|
155
207
|
|
|
156
208
|
function buildModelUrlCandidates(baseUrl) {
|
|
157
209
|
let url;
|
|
158
|
-
try { url = new URL(normalizeAnthropicBaseUrl(baseUrl)); } catch { return []; }
|
|
210
|
+
try { url = new URL(normalizeAnthropicBaseUrl(normalizeOpenAiBaseUrl(baseUrl))); } catch { return []; }
|
|
159
211
|
|
|
160
212
|
const pathname = url.pathname.replace(/\/+$/, '');
|
|
161
213
|
const candidates = [];
|
|
@@ -209,10 +261,7 @@ async function fetchModels(providerKey, apiKey, modelsUrlOverride) {
|
|
|
209
261
|
const res = await fetch(modelsUrl, {
|
|
210
262
|
method: 'GET',
|
|
211
263
|
signal: controller.signal,
|
|
212
|
-
headers:
|
|
213
|
-
authorization: `Bearer ${apiKey}`,
|
|
214
|
-
'api-key': apiKey, // MiMo 用这个 header
|
|
215
|
-
},
|
|
264
|
+
headers: buildProviderAuthHeaders(providerKey, apiKey),
|
|
216
265
|
});
|
|
217
266
|
if (!res.ok) return null;
|
|
218
267
|
|
|
@@ -225,9 +274,22 @@ async function fetchModels(providerKey, apiKey, modelsUrlOverride) {
|
|
|
225
274
|
}
|
|
226
275
|
}
|
|
227
276
|
|
|
228
|
-
function
|
|
277
|
+
function migrateConfig(config) {
|
|
278
|
+
if (!config || typeof config !== 'object') return config;
|
|
279
|
+
if (config.provider === 'kimi') {
|
|
280
|
+
return {
|
|
281
|
+
...config,
|
|
282
|
+
protocol: 'openai',
|
|
283
|
+
baseUrl: PROVIDERS.kimi.baseUrl,
|
|
284
|
+
modelsUrl: PROVIDERS.kimi.modelsUrl,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return config;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function loadConfig(options = {}) {
|
|
229
291
|
try {
|
|
230
|
-
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
292
|
+
return migrateConfig(JSON.parse(fs.readFileSync(options.configFile || CONFIG_FILE, 'utf8')));
|
|
231
293
|
} catch {
|
|
232
294
|
return null;
|
|
233
295
|
}
|
|
@@ -259,16 +321,54 @@ function providerKeyFromBaseUrl(baseUrl) {
|
|
|
259
321
|
return Object.entries(PROVIDERS).find(([, provider]) => provider.baseUrl === baseUrl)?.[0];
|
|
260
322
|
}
|
|
261
323
|
|
|
324
|
+
function getProviderProtocol(config = {}) {
|
|
325
|
+
return config.protocol || PROVIDERS[config.provider]?.protocol || 'anthropic';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function buildProviderAuthHeaders(providerKey, apiKey) {
|
|
329
|
+
const headers = { authorization: `Bearer ${apiKey}` };
|
|
330
|
+
if (getProviderProtocol({ provider: providerKey }) !== 'openai') {
|
|
331
|
+
headers['api-key'] = apiKey; // MiMo 用这个 header
|
|
332
|
+
}
|
|
333
|
+
return headers;
|
|
334
|
+
}
|
|
335
|
+
|
|
262
336
|
function resolveFastModel(provider, model) {
|
|
263
337
|
if (/flash|turbo|haiku|air|lite/i.test(model)) return model;
|
|
264
338
|
return provider?.fastModel || model;
|
|
265
339
|
}
|
|
266
340
|
|
|
267
|
-
function
|
|
341
|
+
function getModelFamily(model) {
|
|
342
|
+
const value = String(model || '').trim().toLowerCase();
|
|
343
|
+
if (!value) return '';
|
|
344
|
+
const normalized = value
|
|
345
|
+
.replace(/^anthropic\//, '')
|
|
346
|
+
.replace(/^\w+\//, '');
|
|
347
|
+
return normalized.split(/[-_.]/).filter(Boolean)[0] || normalized;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function sortRelatedModelIds(ids, mainModel) {
|
|
351
|
+
const unique = [...new Set(ids.filter(Boolean))];
|
|
352
|
+
const family = getModelFamily(mainModel);
|
|
353
|
+
return [
|
|
354
|
+
...unique.filter((id) => id === mainModel),
|
|
355
|
+
...unique.filter((id) => id !== mainModel && getModelFamily(id) === family),
|
|
356
|
+
...unique.filter((id) => id !== mainModel && getModelFamily(id) !== family),
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function buildGatewayBaseUrlForEnv(config) {
|
|
361
|
+
const port = Number.parseInt(config.desktopGatewayPort || 18080, 10);
|
|
362
|
+
return `http://127.0.0.1:${port}/yingclaw`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function buildClaudeEnv(config) {
|
|
366
|
+
const { provider, baseUrl, apiKey, model, fastModel } = config;
|
|
268
367
|
const resolvedFastModel = fastModel || resolveFastModel(PROVIDERS[provider], model);
|
|
368
|
+
const useLocalGateway = getProviderProtocol(config) === 'openai' && config.desktopGatewayKey;
|
|
269
369
|
return {
|
|
270
|
-
ANTHROPIC_BASE_URL: baseUrl,
|
|
271
|
-
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
370
|
+
ANTHROPIC_BASE_URL: useLocalGateway ? buildGatewayBaseUrlForEnv(config) : baseUrl,
|
|
371
|
+
ANTHROPIC_AUTH_TOKEN: useLocalGateway ? config.desktopGatewayKey : apiKey,
|
|
272
372
|
ANTHROPIC_MODEL: model,
|
|
273
373
|
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
274
374
|
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
@@ -311,7 +411,9 @@ function classifyValidationStatus(statusCode) {
|
|
|
311
411
|
async function validateKey(config, options = {}) {
|
|
312
412
|
let url;
|
|
313
413
|
try {
|
|
314
|
-
url =
|
|
414
|
+
url = getProviderProtocol(config) === 'openai'
|
|
415
|
+
? openAiChatCompletionsUrl(config.baseUrl)
|
|
416
|
+
: `${normalizeAnthropicBaseUrl(config.baseUrl)}/v1/messages`;
|
|
315
417
|
new URL(url);
|
|
316
418
|
} catch {
|
|
317
419
|
return null;
|
|
@@ -325,16 +427,21 @@ async function validateKey(config, options = {}) {
|
|
|
325
427
|
signal: controller.signal,
|
|
326
428
|
headers: {
|
|
327
429
|
'content-type': 'application/json',
|
|
328
|
-
|
|
329
|
-
'x-api-key': config.apiKey,
|
|
330
|
-
'
|
|
331
|
-
'anthropic-version': '2023-06-01',
|
|
430
|
+
...buildProviderAuthHeaders(config.provider, config.apiKey),
|
|
431
|
+
...(getProviderProtocol(config) === 'anthropic' ? { 'x-api-key': config.apiKey } : {}),
|
|
432
|
+
...(getProviderProtocol(config) === 'anthropic' ? { 'anthropic-version': '2023-06-01' } : {}),
|
|
332
433
|
},
|
|
333
|
-
body: JSON.stringify(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
434
|
+
body: JSON.stringify(getProviderProtocol(config) === 'openai'
|
|
435
|
+
? {
|
|
436
|
+
model: config.model,
|
|
437
|
+
max_tokens: 1,
|
|
438
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
439
|
+
}
|
|
440
|
+
: {
|
|
441
|
+
model: config.model,
|
|
442
|
+
max_tokens: 1,
|
|
443
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
444
|
+
}),
|
|
338
445
|
});
|
|
339
446
|
return classifyValidationStatus(res.status);
|
|
340
447
|
} catch {
|
|
@@ -465,6 +572,11 @@ module.exports = {
|
|
|
465
572
|
buildModelUrlCandidates,
|
|
466
573
|
fetchModelsFromBaseUrl,
|
|
467
574
|
resolveFastModel,
|
|
575
|
+
getModelFamily,
|
|
576
|
+
sortRelatedModelIds,
|
|
577
|
+
isChatModelId,
|
|
578
|
+
buildProviderAuthHeaders,
|
|
579
|
+
getProviderProtocol,
|
|
468
580
|
providerKeyFromBaseUrl,
|
|
469
581
|
buildClaudeEnv,
|
|
470
582
|
buildEnvBlock,
|
|
@@ -473,5 +585,6 @@ module.exports = {
|
|
|
473
585
|
PROVIDERS,
|
|
474
586
|
CONFIG_FILE,
|
|
475
587
|
CLAUDE_ENV_KEYS,
|
|
588
|
+
VSCODE_CLAUDE_ENV_KEYS,
|
|
476
589
|
CLEAR_CLAUDE_ENV_KEYS,
|
|
477
590
|
};
|
package/lib/doctor.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
CLAUDE_ENV_KEYS,
|
|
13
13
|
} = require('./config');
|
|
14
14
|
const { isDesktopConfigured } = require('./desktop');
|
|
15
|
+
const { checkClaudeCodeSettingsEnv } = require('./vscode');
|
|
15
16
|
|
|
16
17
|
const STATUS_OK = 'ok';
|
|
17
18
|
const STATUS_FAIL = 'fail';
|
|
@@ -104,7 +105,16 @@ async function runDoctorChecks(options = {}) {
|
|
|
104
105
|
: (platform === 'win32' ? '重新打开 PowerShell / CMD' : '运行 source ~/.zshrc 或重开终端'),
|
|
105
106
|
});
|
|
106
107
|
|
|
107
|
-
// 6.
|
|
108
|
+
// 6. VS Code / Claude Code 扩展共享设置
|
|
109
|
+
const vscodeSettings = checkClaudeCodeSettingsEnv(config, options);
|
|
110
|
+
checks.push({
|
|
111
|
+
name: 'VS Code Claude Code',
|
|
112
|
+
status: vscodeSettings.configured ? STATUS_OK : STATUS_WARN,
|
|
113
|
+
message: vscodeSettings.configured ? '共享 settings.json 已写入' : `${vscodeSettings.missing.length} 个变量未写入共享设置`,
|
|
114
|
+
fix: vscodeSettings.configured ? null : '运行 claw vscode,并重启 VS Code',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 7. API Key(顺便确认网络可达)
|
|
108
118
|
const valid = await validateKey(config, { timeoutMs: 6000 });
|
|
109
119
|
if (valid === true) {
|
|
110
120
|
checks.push({ name: 'API Key', status: STATUS_OK, message: '校验通过' });
|
|
@@ -124,7 +134,7 @@ async function runDoctorChecks(options = {}) {
|
|
|
124
134
|
});
|
|
125
135
|
}
|
|
126
136
|
|
|
127
|
-
//
|
|
137
|
+
// 8. Claude 桌面应用接入状态
|
|
128
138
|
const desktopConfigured = isDesktopConfigured();
|
|
129
139
|
checks.push({
|
|
130
140
|
name: 'Claude 桌面应用',
|
|
@@ -132,7 +142,7 @@ async function runDoctorChecks(options = {}) {
|
|
|
132
142
|
message: desktopConfigured ? '已通过 yingclaw 接入' : '未接入(如需运行 claw desktop)',
|
|
133
143
|
});
|
|
134
144
|
|
|
135
|
-
//
|
|
145
|
+
// 9. DeepSeek 旧模型名提醒
|
|
136
146
|
if (config.provider === 'deepseek' && (
|
|
137
147
|
config.model === 'deepseek-v4-pro' ||
|
|
138
148
|
config.model === 'deepseek-v4-flash' ||
|
package/lib/gateway.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const http = require('http');
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
loadConfig: defaultLoadConfig,
|
|
5
|
+
normalizeAnthropicBaseUrl,
|
|
6
|
+
buildProviderAuthHeaders,
|
|
7
|
+
getProviderProtocol,
|
|
8
|
+
isChatModelId,
|
|
9
|
+
} = require('./config');
|
|
10
|
+
const {
|
|
11
|
+
anthropicToOpenAiChatRequest,
|
|
12
|
+
openAiChatCompletionsUrl,
|
|
13
|
+
openAiStreamChunkToAnthropicEvents,
|
|
14
|
+
openAiToAnthropicMessage,
|
|
15
|
+
} = require('./openai');
|
|
4
16
|
|
|
5
17
|
const DEFAULT_DESKTOP_GATEWAY_PORT = 18080;
|
|
6
18
|
const YINGCLAW_GATEWAY_PREFIX = '/yingclaw';
|
|
@@ -59,9 +71,7 @@ function desktopRouteLabel(routeId) {
|
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
function isDesktopChatModel(model) {
|
|
62
|
-
|
|
63
|
-
return !/(^|[-_])(tts|voice|voiceclone|voicedesign|speech|audio|image|video|embedding|embed|rerank|moderation)([-_]|$)/i.test(value)
|
|
64
|
-
&& !/(cogview|cogvideo|wanx|flux|sdxl|stable-diffusion)/i.test(value);
|
|
74
|
+
return isChatModelId(model);
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
function buildDesktopGatewayRoutes(config) {
|
|
@@ -93,6 +103,13 @@ function buildDesktopGatewayMappingRows(config) {
|
|
|
93
103
|
|
|
94
104
|
function mapDesktopRouteToUpstream(config, routeId) {
|
|
95
105
|
const requested = stripOneMContextSuffix(routeId);
|
|
106
|
+
const configuredModels = [
|
|
107
|
+
config.model,
|
|
108
|
+
config.fastModel || config.model,
|
|
109
|
+
...(Array.isArray(config.availableModels) ? config.availableModels : []),
|
|
110
|
+
].filter(Boolean);
|
|
111
|
+
if (configuredModels.includes(routeId)) return routeId;
|
|
112
|
+
|
|
96
113
|
const route = buildDesktopGatewayRoutes(config).find((item) => stripOneMContextSuffix(item.id) === requested);
|
|
97
114
|
if (!route) {
|
|
98
115
|
throw new Error(`未配置的 Claude Desktop 模型路由: ${routeId}`);
|
|
@@ -147,6 +164,10 @@ function providerMessagesUrl(config) {
|
|
|
147
164
|
return `${normalizeAnthropicBaseUrl(config.baseUrl)}/v1/messages`;
|
|
148
165
|
}
|
|
149
166
|
|
|
167
|
+
function providerOpenAiChatUrl(config) {
|
|
168
|
+
return openAiChatCompletionsUrl(config.baseUrl);
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
function buildDesktopGatewayUrl(config = {}) {
|
|
151
172
|
const port = Number.parseInt(config.desktopGatewayPort || DEFAULT_DESKTOP_GATEWAY_PORT, 10);
|
|
152
173
|
return {
|
|
@@ -203,14 +224,18 @@ async function proxyMessages(req, res, config) {
|
|
|
203
224
|
return;
|
|
204
225
|
}
|
|
205
226
|
|
|
227
|
+
if (getProviderProtocol(config) === 'openai') {
|
|
228
|
+
await proxyOpenAiMessages(res, config, body);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
206
232
|
const upstream = await fetch(providerMessagesUrl(config), {
|
|
207
233
|
method: 'POST',
|
|
208
234
|
headers: {
|
|
209
235
|
'content-type': 'application/json',
|
|
210
236
|
accept: body.stream ? 'text/event-stream' : 'application/json',
|
|
211
|
-
|
|
237
|
+
...buildProviderAuthHeaders(config.provider, config.apiKey),
|
|
212
238
|
'x-api-key': config.apiKey,
|
|
213
|
-
'api-key': config.apiKey,
|
|
214
239
|
'anthropic-version': '2023-06-01',
|
|
215
240
|
},
|
|
216
241
|
body: JSON.stringify(body),
|
|
@@ -231,6 +256,80 @@ async function proxyMessages(req, res, config) {
|
|
|
231
256
|
res.end();
|
|
232
257
|
}
|
|
233
258
|
|
|
259
|
+
async function proxyOpenAiMessages(res, config, body) {
|
|
260
|
+
const openAiBody = anthropicToOpenAiChatRequest(body);
|
|
261
|
+
const upstream = await fetch(providerOpenAiChatUrl(config), {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: {
|
|
264
|
+
'content-type': 'application/json',
|
|
265
|
+
accept: body.stream ? 'text/event-stream' : 'application/json',
|
|
266
|
+
...buildProviderAuthHeaders(config.provider, config.apiKey),
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify(openAiBody),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!body.stream) {
|
|
272
|
+
const text = await upstream.text();
|
|
273
|
+
if (!upstream.ok) {
|
|
274
|
+
res.writeHead(upstream.status, {
|
|
275
|
+
'content-type': upstream.headers.get('content-type') || 'application/json',
|
|
276
|
+
});
|
|
277
|
+
res.end(text);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = text ? JSON.parse(text) : {};
|
|
284
|
+
} catch {
|
|
285
|
+
sendJson(res, 502, { error: { message: 'OpenAI 兼容接口返回了无效 JSON' } });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
sendJson(res, upstream.status, openAiToAnthropicMessage(parsed, body.model));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
res.writeHead(upstream.status, {
|
|
293
|
+
'content-type': upstream.ok ? 'text/event-stream' : (upstream.headers.get('content-type') || 'application/json'),
|
|
294
|
+
'cache-control': 'no-cache',
|
|
295
|
+
});
|
|
296
|
+
if (!upstream.body) {
|
|
297
|
+
res.end();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!upstream.ok) {
|
|
301
|
+
for await (const chunk of upstream.body) res.write(chunk);
|
|
302
|
+
res.end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const state = { model: body.model, started: false, finished: false };
|
|
307
|
+
let buffer = '';
|
|
308
|
+
const decoder = new TextDecoder();
|
|
309
|
+
for await (const chunk of upstream.body) {
|
|
310
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
311
|
+
const frames = buffer.split(/\n\n/);
|
|
312
|
+
buffer = frames.pop() || '';
|
|
313
|
+
for (const frame of frames) {
|
|
314
|
+
const dataLine = frame.split(/\n/).find((line) => line.startsWith('data:'));
|
|
315
|
+
if (!dataLine) continue;
|
|
316
|
+
const payload = dataLine.slice('data:'.length).trim();
|
|
317
|
+
if (!payload || payload === '[DONE]') continue;
|
|
318
|
+
let parsed;
|
|
319
|
+
try {
|
|
320
|
+
parsed = JSON.parse(payload);
|
|
321
|
+
} catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
res.write(openAiStreamChunkToAnthropicEvents(parsed, state));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!state.finished) {
|
|
328
|
+
res.write(openAiStreamChunkToAnthropicEvents({ model: body.model, choices: [{ delta: {}, finish_reason: 'stop' }] }, state));
|
|
329
|
+
}
|
|
330
|
+
res.end();
|
|
331
|
+
}
|
|
332
|
+
|
|
234
333
|
function createGatewayServer(options = {}) {
|
|
235
334
|
const loadConfig = options.loadConfig || defaultLoadConfig;
|
|
236
335
|
return http.createServer(async (req, res) => {
|
package/lib/openai.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
function normalizeOpenAiBaseUrl(baseUrl) {
|
|
2
|
+
let url;
|
|
3
|
+
try { url = new URL(baseUrl); } catch { return baseUrl; }
|
|
4
|
+
|
|
5
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
6
|
+
if (parts.at(-1) === 'completions' && parts.at(-2) === 'chat' && parts.at(-3) === 'v1') {
|
|
7
|
+
parts.splice(-3, 3);
|
|
8
|
+
} else if (parts.at(-1) === 'models' && parts.at(-2) === 'v1') {
|
|
9
|
+
parts.splice(-2, 2);
|
|
10
|
+
} else if (parts.at(-1) === 'v1') {
|
|
11
|
+
parts.pop();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
url.pathname = parts.length > 0 ? `/${parts.join('/')}` : '/';
|
|
15
|
+
url.search = '';
|
|
16
|
+
url.hash = '';
|
|
17
|
+
return url.toString().replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function openAiChatCompletionsUrl(baseUrl) {
|
|
21
|
+
return `${normalizeOpenAiBaseUrl(baseUrl)}/v1/chat/completions`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function anthropicTextFromContent(content) {
|
|
25
|
+
if (typeof content === 'string') return content;
|
|
26
|
+
if (!Array.isArray(content)) return '';
|
|
27
|
+
return content
|
|
28
|
+
.map((part) => {
|
|
29
|
+
if (typeof part === 'string') return part;
|
|
30
|
+
if (part?.type === 'text') return part.text || '';
|
|
31
|
+
return '';
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeSystemMessages(system) {
|
|
38
|
+
if (!system) return [];
|
|
39
|
+
if (typeof system === 'string') return [{ role: 'system', content: system }];
|
|
40
|
+
if (Array.isArray(system)) {
|
|
41
|
+
const text = anthropicTextFromContent(system);
|
|
42
|
+
return text ? [{ role: 'system', content: text }] : [];
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function anthropicToOpenAiChatRequest(body) {
|
|
48
|
+
const messages = [
|
|
49
|
+
...normalizeSystemMessages(body.system),
|
|
50
|
+
...(Array.isArray(body.messages) ? body.messages : []).map((message) => ({
|
|
51
|
+
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
52
|
+
content: anthropicTextFromContent(message.content),
|
|
53
|
+
})),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const request = {
|
|
57
|
+
model: body.model,
|
|
58
|
+
messages,
|
|
59
|
+
stream: !!body.stream,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (body.max_tokens !== undefined) request.max_tokens = body.max_tokens;
|
|
63
|
+
if (body.temperature !== undefined) request.temperature = body.temperature;
|
|
64
|
+
if (body.top_p !== undefined) request.top_p = body.top_p;
|
|
65
|
+
if (body.stop_sequences !== undefined) request.stop = body.stop_sequences;
|
|
66
|
+
return request;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openAiFinishReasonToAnthropic(reason) {
|
|
70
|
+
if (reason === 'length') return 'max_tokens';
|
|
71
|
+
if (reason === 'tool_calls' || reason === 'function_call') return 'tool_use';
|
|
72
|
+
if (reason === 'stop') return 'end_turn';
|
|
73
|
+
return reason || 'end_turn';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function openAiToAnthropicMessage(body, fallbackModel) {
|
|
77
|
+
const choice = body?.choices?.[0] || {};
|
|
78
|
+
const content = choice.message?.content ?? choice.text ?? '';
|
|
79
|
+
const text = Array.isArray(content)
|
|
80
|
+
? content.map((part) => part?.text || '').join('')
|
|
81
|
+
: String(content || '');
|
|
82
|
+
return {
|
|
83
|
+
id: body?.id || `msg_${Date.now()}`,
|
|
84
|
+
type: 'message',
|
|
85
|
+
role: 'assistant',
|
|
86
|
+
model: body?.model || fallbackModel,
|
|
87
|
+
content: [{ type: 'text', text }],
|
|
88
|
+
stop_reason: openAiFinishReasonToAnthropic(choice.finish_reason),
|
|
89
|
+
stop_sequence: null,
|
|
90
|
+
usage: {
|
|
91
|
+
input_tokens: body?.usage?.prompt_tokens ?? body?.usage?.input_tokens ?? 0,
|
|
92
|
+
output_tokens: body?.usage?.completion_tokens ?? body?.usage?.output_tokens ?? 0,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function anthropicSse(event, data) {
|
|
98
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function openAiStreamChunkToAnthropicEvents(chunk, state) {
|
|
102
|
+
const choice = chunk?.choices?.[0] || {};
|
|
103
|
+
const delta = choice.delta || {};
|
|
104
|
+
const events = [];
|
|
105
|
+
if (!state.started) {
|
|
106
|
+
state.started = true;
|
|
107
|
+
events.push(anthropicSse('message_start', {
|
|
108
|
+
type: 'message_start',
|
|
109
|
+
message: {
|
|
110
|
+
id: chunk?.id || `msg_${Date.now()}`,
|
|
111
|
+
type: 'message',
|
|
112
|
+
role: 'assistant',
|
|
113
|
+
model: chunk?.model || state.model,
|
|
114
|
+
content: [],
|
|
115
|
+
stop_reason: null,
|
|
116
|
+
stop_sequence: null,
|
|
117
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
events.push(anthropicSse('content_block_start', {
|
|
121
|
+
type: 'content_block_start',
|
|
122
|
+
index: 0,
|
|
123
|
+
content_block: { type: 'text', text: '' },
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
if (delta.content) {
|
|
127
|
+
events.push(anthropicSse('content_block_delta', {
|
|
128
|
+
type: 'content_block_delta',
|
|
129
|
+
index: 0,
|
|
130
|
+
delta: { type: 'text_delta', text: delta.content },
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
if (choice.finish_reason) {
|
|
134
|
+
state.finished = true;
|
|
135
|
+
events.push(anthropicSse('content_block_stop', { type: 'content_block_stop', index: 0 }));
|
|
136
|
+
events.push(anthropicSse('message_delta', {
|
|
137
|
+
type: 'message_delta',
|
|
138
|
+
delta: { stop_reason: openAiFinishReasonToAnthropic(choice.finish_reason), stop_sequence: null },
|
|
139
|
+
usage: { output_tokens: 0 },
|
|
140
|
+
}));
|
|
141
|
+
events.push(anthropicSse('message_stop', { type: 'message_stop' }));
|
|
142
|
+
}
|
|
143
|
+
return events.join('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
anthropicToOpenAiChatRequest,
|
|
148
|
+
normalizeOpenAiBaseUrl,
|
|
149
|
+
openAiChatCompletionsUrl,
|
|
150
|
+
openAiStreamChunkToAnthropicEvents,
|
|
151
|
+
openAiToAnthropicMessage,
|
|
152
|
+
};
|
package/lib/panel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { buildClaudeEnv, PROVIDERS } = require('./config');
|
|
1
|
+
const { buildClaudeEnv, getModelFamily, PROVIDERS } = require('./config');
|
|
2
2
|
|
|
3
3
|
function apiStatusText(apiStatus) {
|
|
4
4
|
if (apiStatus === true) return 'API 正常';
|
|
@@ -39,6 +39,9 @@ function buildStatusView(config, options = {}) {
|
|
|
39
39
|
const desktopGatewayStatus = options.desktopGatewayStatus;
|
|
40
40
|
const desktopGatewayAutostartStatus = options.desktopGatewayAutostartStatus;
|
|
41
41
|
const desktopGatewayText = desktopGatewayStatusText(desktopGatewayStatus, desktopGatewayAutostartStatus);
|
|
42
|
+
const availableModelCount = Array.isArray(config.availableModels) ? config.availableModels.length : 0;
|
|
43
|
+
const mainFamily = getModelFamily(mainModel);
|
|
44
|
+
const fastFamily = getModelFamily(fastModel);
|
|
42
45
|
const warnings = [];
|
|
43
46
|
|
|
44
47
|
if (config.provider === 'deepseek' && (
|
|
@@ -48,11 +51,15 @@ function buildStatusView(config, options = {}) {
|
|
|
48
51
|
)) {
|
|
49
52
|
warnings.push('检测到旧 DeepSeek 模型名,建议运行 claw switch 更新到 [1m] 长上下文版本');
|
|
50
53
|
}
|
|
54
|
+
if (config.provider === 'custom' && mainFamily && fastFamily && mainFamily !== fastFamily) {
|
|
55
|
+
warnings.push(`快速模型跨系列:${mainFamily} → ${fastFamily}`);
|
|
56
|
+
}
|
|
51
57
|
|
|
52
58
|
const view = {
|
|
53
59
|
providerName,
|
|
54
60
|
mainModel,
|
|
55
61
|
fastModel,
|
|
62
|
+
availableModelCount,
|
|
56
63
|
envActive,
|
|
57
64
|
warnings,
|
|
58
65
|
lines: [
|
|
@@ -83,14 +90,15 @@ function buildMenuStatusLines(view, options = {}) {
|
|
|
83
90
|
];
|
|
84
91
|
|
|
85
92
|
if (view.envActive) {
|
|
86
|
-
lines.push('
|
|
93
|
+
lines.push('终端已生效');
|
|
87
94
|
} else if (options.platform === 'win32') {
|
|
88
|
-
lines.push('
|
|
95
|
+
lines.push('终端未生效:重新打开 PowerShell / CMD');
|
|
89
96
|
} else {
|
|
90
|
-
lines.push('
|
|
97
|
+
lines.push('终端未生效:source ~/.zshrc 或重开终端');
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
|
|
100
|
+
const modelCountText = view.availableModelCount ? ` · 可用 ${view.availableModelCount}` : '';
|
|
101
|
+
lines.push(`模型 ${view.mainModel} · 快速 ${view.fastModel}${modelCountText}`);
|
|
94
102
|
|
|
95
103
|
const desktopGatewayText = desktopGatewayStatusText(
|
|
96
104
|
view.desktopGatewayStatus || options.desktopGatewayStatus,
|
|
@@ -103,6 +111,10 @@ function buildMenuStatusLines(view, options = {}) {
|
|
|
103
111
|
if (view.warnings.some((warning) => warning.includes('旧 DeepSeek 模型名'))) {
|
|
104
112
|
lines.push('旧模型名:选择下方"切换厂商或模型"更新到 [1m]');
|
|
105
113
|
}
|
|
114
|
+
const crossFamilyWarning = view.warnings.find((warning) => warning.startsWith('快速模型跨系列'));
|
|
115
|
+
if (crossFamilyWarning) {
|
|
116
|
+
lines.push(crossFamilyWarning);
|
|
117
|
+
}
|
|
106
118
|
|
|
107
119
|
return lines;
|
|
108
120
|
}
|