imtoagent 0.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/README.md +234 -0
- package/bin/imtoagent +453 -0
- package/index.ts +1129 -0
- package/modules/agent/claude-adapter.ts +258 -0
- package/modules/agent/claude.ts +160 -0
- package/modules/agent/codex-adapter.ts +232 -0
- package/modules/agent/codex-exec-server.ts +513 -0
- package/modules/agent/codex.ts +275 -0
- package/modules/agent/opencode-adapter.ts +308 -0
- package/modules/agent/opencode.ts +247 -0
- package/modules/bot-context.ts +26 -0
- package/modules/capabilities.ts +189 -0
- package/modules/cli/setup.ts +424 -0
- package/modules/core/config.ts +275 -0
- package/modules/core/error.ts +124 -0
- package/modules/core/index.ts +39 -0
- package/modules/core/runtime.ts +282 -0
- package/modules/core/session.ts +256 -0
- package/modules/core/stats.ts +92 -0
- package/modules/core/types.ts +250 -0
- package/modules/im/feishu.ts +731 -0
- package/modules/im/telegram.ts +639 -0
- package/modules/im/wechat.ts +1094 -0
- package/modules/im/wecom.ts +603 -0
- package/modules/media/feishu-inbound-adapter.ts +108 -0
- package/modules/media/index.ts +27 -0
- package/modules/media/media-store.ts +273 -0
- package/modules/media/resolver.ts +178 -0
- package/modules/media/telegram-inbound-adapter.ts +124 -0
- package/modules/media/types.ts +76 -0
- package/modules/prompt-builder.ts +123 -0
- package/modules/proxy/anthropic-proxy.ts +1083 -0
- package/modules/proxy/codex-proxy.ts +657 -0
- package/modules/rate-limiter.ts +58 -0
- package/modules/types.ts +144 -0
- package/modules/utils/backend-check.ts +121 -0
- package/modules/utils/paths.ts +218 -0
- package/package.json +53 -0
- package/scripts/postinstall.ts +70 -0
- package/templates/config.template.json +57 -0
- package/templates/opencode.template.json +28 -0
- package/templates/providers.template.json +19 -0
- package/templates/soul.template/identity.md +6 -0
- package/templates/soul.template/profile.md +11 -0
- package/templates/soul.template/rules.md +7 -0
- package/templates/soul.template/skills.md +3 -0
- package/templates/soul.template/workspace.md +4 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
// CC 代理本地 HTTP 代理 — 实现 Claude Code 运行时模型热切换
|
|
2
|
+
//
|
|
3
|
+
// 架构:
|
|
4
|
+
// Claude Code subprocess → http://localhost:18899/v1/messages (Anthropic 格式)
|
|
5
|
+
// → 读 sharedState.activeConfig
|
|
6
|
+
// → 格式转换(Anthropic ↔ OpenAI)
|
|
7
|
+
// → 转发到真实供应商 API
|
|
8
|
+
// → 返回 Anthropic 兼容响应
|
|
9
|
+
//
|
|
10
|
+
// 支持:
|
|
11
|
+
// - Anthropic 格式供应商(小米、DeepSeek):直接透传
|
|
12
|
+
// - OpenAI 格式供应商(百炼、极速):自动双向转换
|
|
13
|
+
|
|
14
|
+
import * as http from 'http';
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { getCurrentBot } from '../bot-context';
|
|
18
|
+
import { handleCodexRequest } from './codex-proxy';
|
|
19
|
+
import { getDataDir, getSessionsDir } from '../utils/paths';
|
|
20
|
+
|
|
21
|
+
// ===== 共享状态 =====
|
|
22
|
+
export interface ModelAliases {
|
|
23
|
+
default: string;
|
|
24
|
+
sonnet: string;
|
|
25
|
+
opus: string;
|
|
26
|
+
haiku: string;
|
|
27
|
+
best: string;
|
|
28
|
+
opencode?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProxyConfig {
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
apiKey: string;
|
|
34
|
+
model: string;
|
|
35
|
+
providerName: string;
|
|
36
|
+
format: 'anthropic' | 'openai';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const sharedState = {
|
|
40
|
+
activeConfig: null as ProxyConfig | null,
|
|
41
|
+
modelAliases: null as ModelAliases | null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ===== 供应商配置 =====
|
|
45
|
+
interface ProviderConfig {
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
apiKey: string;
|
|
48
|
+
models: string[];
|
|
49
|
+
format?: 'anthropic' | 'openai';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let providers = new Map<string, ProviderConfig>();
|
|
53
|
+
|
|
54
|
+
const CONFIG_PATH = path.join(getDataDir(), 'providers.json');
|
|
55
|
+
|
|
56
|
+
export function loadProviders(): { providers: Map<string, ProviderConfig>; defaultModel: string } {
|
|
57
|
+
providers = new Map<string, ProviderConfig>();
|
|
58
|
+
let defaultModel = 'xiaomi/mimo-v2.5-pro';
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
61
|
+
const cfg = JSON.parse(raw);
|
|
62
|
+
// activeModel 优先于 defaultModel(持久化的用户选择)
|
|
63
|
+
if (cfg.activeModel) defaultModel = cfg.activeModel;
|
|
64
|
+
else if (cfg.defaultModel) defaultModel = cfg.defaultModel;
|
|
65
|
+
const provs = cfg.providers || {};
|
|
66
|
+
for (const [name, p] of Object.entries(provs) as [string, any][]) {
|
|
67
|
+
providers.set(name, {
|
|
68
|
+
baseUrl: p.baseUrl || '',
|
|
69
|
+
apiKey: p.apiKey || '',
|
|
70
|
+
models: p.models || [],
|
|
71
|
+
format: p.format || 'anthropic',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
console.log(`[Proxy] 加载 ${providers.size} 个供应商: ${[...providers.keys()].join(', ')}`);
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
console.error(`[Proxy] 读取 providers.json 失败: ${e.message}`);
|
|
77
|
+
}
|
|
78
|
+
return { providers, defaultModel };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 持久化当前模型选择到 providers.json */
|
|
82
|
+
export function saveActiveModel(modelSpec: string): void {
|
|
83
|
+
try {
|
|
84
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
85
|
+
const cfg = JSON.parse(raw);
|
|
86
|
+
cfg.activeModel = modelSpec;
|
|
87
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
88
|
+
console.log(`[Proxy] activeModel 已持久化: ${modelSpec}`);
|
|
89
|
+
} catch (e: any) {
|
|
90
|
+
console.error(`[Proxy] 保存 activeModel 失败: ${e.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ===== 会话级模型映射 =====
|
|
95
|
+
const SESSIONS_DIR = () => getSessionsDir();
|
|
96
|
+
|
|
97
|
+
// ===== reasoning_content 缓存(跨请求持久化,用于下游 client 丢失 thinking 块时注入) =====
|
|
98
|
+
const reasoningCache = new Map<string, string>();
|
|
99
|
+
|
|
100
|
+
/** 根据消息历史生成简单会话指纹(用第一条 user 消息,避免 tool_result 污染) */
|
|
101
|
+
function conversationFingerprint(messages: any[]): string {
|
|
102
|
+
// OpenCode 在工具执行后,消息列表中最后一条 "user" 是 tool_result,
|
|
103
|
+
// 会导致指纹变化、reasoning cache miss。改为用第一条 user 消息。
|
|
104
|
+
for (let i = 0; i < messages.length; i++) {
|
|
105
|
+
if (messages[i].role === 'user') {
|
|
106
|
+
const content = typeof messages[i].content === 'string'
|
|
107
|
+
? messages[i].content
|
|
108
|
+
: JSON.stringify(messages[i].content);
|
|
109
|
+
return content.slice(0, 200);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 加载用户会话配置 */
|
|
116
|
+
export function loadSessionConfig(customPath?: string): { activeModel: string; modelAliases: ModelAliases } {
|
|
117
|
+
const sessionPath = customPath || `${SESSIONS_DIR()}/_default.json`;
|
|
118
|
+
const defaultAliases: ModelAliases = {
|
|
119
|
+
default: 'deepseek/deepseek-v4-pro[1m]',
|
|
120
|
+
sonnet: 'deepseek/deepseek-v4-flash[1m]',
|
|
121
|
+
opus: 'deepseek/deepseek-v4-pro[1m]',
|
|
122
|
+
haiku: 'deepseek/deepseek-v4-flash[1m]',
|
|
123
|
+
best: 'deepseek/deepseek-v4-pro[1m]',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(sessionPath)) {
|
|
128
|
+
return { activeModel: defaultAliases.default, modelAliases: defaultAliases };
|
|
129
|
+
}
|
|
130
|
+
const raw = fs.readFileSync(sessionPath, 'utf-8');
|
|
131
|
+
const cfg = JSON.parse(raw);
|
|
132
|
+
return {
|
|
133
|
+
activeModel: cfg.activeModel || defaultAliases.default,
|
|
134
|
+
modelAliases: cfg.modelAliases || defaultAliases,
|
|
135
|
+
};
|
|
136
|
+
} catch (e: any) {
|
|
137
|
+
console.error(`[Proxy] 加载会话配置失败 (${customPath || '_default.json'}): ${e.message}`);
|
|
138
|
+
return { activeModel: defaultAliases.default, modelAliases: defaultAliases };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 保存用户会话配置 */
|
|
143
|
+
export function saveSessionConfig(userId: string, activeModel: string, modelAliases: ModelAliases): void {
|
|
144
|
+
const sessionsBase = SESSIONS_DIR();
|
|
145
|
+
try {
|
|
146
|
+
if (!fs.existsSync(sessionsBase)) {
|
|
147
|
+
fs.mkdirSync(sessionsBase, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
const sessionPath = `${sessionsBase}/${userId}.json`;
|
|
150
|
+
const cfg = {
|
|
151
|
+
userId,
|
|
152
|
+
activeModel,
|
|
153
|
+
modelAliases,
|
|
154
|
+
lastActive: new Date().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
fs.writeFileSync(sessionPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
157
|
+
} catch (e: any) {
|
|
158
|
+
console.error(`[Proxy] 保存会话配置失败 (${userId}): ${e.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 解析模型名:如果是角色别名,替换为实际模型 */
|
|
163
|
+
export function resolveModel(requestedModel: string, modelAliases: ModelAliases): string {
|
|
164
|
+
const aliasMap: Record<string, keyof ModelAliases> = {
|
|
165
|
+
'default': 'default',
|
|
166
|
+
'sonnet': 'sonnet',
|
|
167
|
+
'opus': 'opus',
|
|
168
|
+
'haiku': 'haiku',
|
|
169
|
+
'best': 'best',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const normalized = requestedModel.toLowerCase();
|
|
173
|
+
if (normalized in aliasMap) {
|
|
174
|
+
return modelAliases[aliasMap[normalized]];
|
|
175
|
+
}
|
|
176
|
+
return requestedModel;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 根据 Claude Code 传的模型名前缀,自动识别角色并替换,返回完整规格 */
|
|
180
|
+
export function resolveModelByPrefix(modelName: string): string {
|
|
181
|
+
// 优先使用当前 bot 级别的别名(支持 /model 热切换),回退到全局配置
|
|
182
|
+
const botCtx = getCurrentBot();
|
|
183
|
+
const aliases = botCtx?.modelAliases || sharedState.modelAliases;
|
|
184
|
+
if (!aliases) return modelName;
|
|
185
|
+
|
|
186
|
+
const lower = modelName.toLowerCase();
|
|
187
|
+
|
|
188
|
+
// Claude Code 模型名模式识别
|
|
189
|
+
if (lower.startsWith('claude-haiku')) {
|
|
190
|
+
return aliases.haiku;
|
|
191
|
+
}
|
|
192
|
+
if (lower.startsWith('claude-sonnet')) {
|
|
193
|
+
return aliases.sonnet;
|
|
194
|
+
}
|
|
195
|
+
if (lower.startsWith('claude-opus')) {
|
|
196
|
+
return aliases.opus;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// OpenCode 独立模型标识(通过 opencode.json 的 models.id 覆盖注入)
|
|
200
|
+
if (lower.startsWith('opencode-')) {
|
|
201
|
+
return (aliases as any).opencode || aliases.sonnet;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 其他情况:返回当前 activeConfig 的完整规格
|
|
205
|
+
if (sharedState.activeConfig) {
|
|
206
|
+
return `${sharedState.activeConfig.providerName}/${sharedState.activeConfig.model}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return modelName;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function getProviderConfig(modelSpec: string): ProxyConfig | null {
|
|
213
|
+
const slashIdx = modelSpec.indexOf('/');
|
|
214
|
+
if (slashIdx < 0) return null;
|
|
215
|
+
const provName = modelSpec.slice(0, slashIdx);
|
|
216
|
+
const modelName = modelSpec.slice(slashIdx + 1);
|
|
217
|
+
const p = providers.get(provName);
|
|
218
|
+
if (!p) return null;
|
|
219
|
+
// 校验模型名是否在供应商的模型列表中
|
|
220
|
+
if (!p.models.includes(modelName)) return null;
|
|
221
|
+
return {
|
|
222
|
+
baseUrl: p.baseUrl,
|
|
223
|
+
apiKey: p.apiKey,
|
|
224
|
+
model: modelName,
|
|
225
|
+
providerName: provName,
|
|
226
|
+
format: (p.format as any) || 'anthropic',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ===== 格式转换工具 =====
|
|
231
|
+
|
|
232
|
+
/** 清理 CC system_reminder,提取用户实际查询 */
|
|
233
|
+
function cleanCCUserContent(text: string): string {
|
|
234
|
+
// 去掉 system-reminder 标签
|
|
235
|
+
let cleaned = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
|
|
236
|
+
// 去掉技能列表等 CC 元信息
|
|
237
|
+
cleaned = cleaned.replace(/The following skills are available[\s\S]*?(?=\n#{1,3} |\nIMPORTANT|\n作为一名|\nYou are|$)/g, '');
|
|
238
|
+
// 提取 IMPORTANT 之后到 "currentDa" / "currentDate" 之前的实际任务
|
|
239
|
+
const importantMatch = cleaned.match(/IMPORTANT:[\s\S]*?should not respond to this context unless it is highly relevant to your task\.\s*\n\s*([^#]*?)(?:\s*# currentDate|\s*#{1,3} |$)/);
|
|
240
|
+
if (importantMatch) {
|
|
241
|
+
cleaned = importantMatch[1].trim();
|
|
242
|
+
}
|
|
243
|
+
// 去掉 CC billing header 等
|
|
244
|
+
cleaned = cleaned.replace(/x-anthropic-billing-header[^\n]*\n?/g, '');
|
|
245
|
+
cleaned = cleaned.trim();
|
|
246
|
+
return cleaned || text;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Anthropic content → OpenAI 字符串 */
|
|
250
|
+
function normalizeAnthropicContent(content: any): string {
|
|
251
|
+
if (typeof content === 'string') return content;
|
|
252
|
+
if (Array.isArray(content)) {
|
|
253
|
+
return content
|
|
254
|
+
.map((block: any) => {
|
|
255
|
+
if (block.type === 'text') return block.text;
|
|
256
|
+
if (block.type === 'tool_use') return `[tool:${block.name}]`;
|
|
257
|
+
if (block.type === 'tool_result') {
|
|
258
|
+
const c = block.content;
|
|
259
|
+
if (typeof c === 'string') return c;
|
|
260
|
+
if (Array.isArray(c)) return c.map((b: any) => b.text || '').join('');
|
|
261
|
+
return '';
|
|
262
|
+
}
|
|
263
|
+
return block.text || '';
|
|
264
|
+
})
|
|
265
|
+
.join('\n');
|
|
266
|
+
}
|
|
267
|
+
return String(content || '');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Anthropic 请求体 → OpenAI 请求体 */
|
|
271
|
+
function anthropicToOpenAI(anthropicBody: any, modelName: string, originalModelName?: string): any {
|
|
272
|
+
const messages: any[] = [];
|
|
273
|
+
|
|
274
|
+
// system 是 Anthropic 顶层字段,转为 OpenAI messages 第一条
|
|
275
|
+
// 必须 normalize,否则 cache_control、billing header 等 Anthropic 特有字段会泄漏
|
|
276
|
+
if (anthropicBody.system) {
|
|
277
|
+
const sysContent = normalizeAnthropicContent(anthropicBody.system);
|
|
278
|
+
if (sysContent) {
|
|
279
|
+
messages.push({ role: 'system', content: sysContent });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const msg of anthropicBody.messages || []) {
|
|
284
|
+
if (msg.role === 'user') {
|
|
285
|
+
// 如果是数组 content,拆分为 text / tool_result / image
|
|
286
|
+
if (Array.isArray(msg.content)) {
|
|
287
|
+
const textParts: string[] = [];
|
|
288
|
+
const imageBlocks: any[] = []; // OpenAI vision 格式的图片块
|
|
289
|
+
for (const block of msg.content) {
|
|
290
|
+
if (block.type === 'tool_result') {
|
|
291
|
+
const tc = block.content;
|
|
292
|
+
const resultText = typeof tc === 'string' ? tc
|
|
293
|
+
: Array.isArray(tc) ? tc.map((b: any) => b.text || '').join('') : String(tc || '');
|
|
294
|
+
messages.push({
|
|
295
|
+
role: 'tool',
|
|
296
|
+
content: resultText,
|
|
297
|
+
tool_call_id: block.tool_use_id || '',
|
|
298
|
+
});
|
|
299
|
+
} else if (block.type === 'text') {
|
|
300
|
+
textParts.push(block.text);
|
|
301
|
+
} else if (block.type === 'image') {
|
|
302
|
+
// 转换 Anthropic 图片块 → OpenAI vision 格式
|
|
303
|
+
if (block.source?.type === 'base64') {
|
|
304
|
+
const mediaType = block.source.media_type || 'image/png';
|
|
305
|
+
imageBlocks.push({
|
|
306
|
+
type: 'image_url',
|
|
307
|
+
image_url: { url: `data:${mediaType};base64,${block.source.data}` },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// 构建 user 消息(文本 + 可能有的图片)
|
|
313
|
+
if (textParts.length > 0 || imageBlocks.length > 0) {
|
|
314
|
+
let content: any;
|
|
315
|
+
if (imageBlocks.length > 0) {
|
|
316
|
+
// 有图片 → 用数组格式
|
|
317
|
+
content = [];
|
|
318
|
+
if (textParts.length > 0) {
|
|
319
|
+
let textContent = textParts.join('\n');
|
|
320
|
+
if (textContent.includes('<system-reminder>') || textContent.includes('x-anthropic-billing-header')) {
|
|
321
|
+
textContent = cleanCCUserContent(textContent);
|
|
322
|
+
}
|
|
323
|
+
content.push({ type: 'text', text: textContent });
|
|
324
|
+
}
|
|
325
|
+
content.push(...imageBlocks);
|
|
326
|
+
} else {
|
|
327
|
+
// 无图片 → 纯字符串
|
|
328
|
+
content = textParts.join('\n');
|
|
329
|
+
if (content.includes('<system-reminder>') || content.includes('x-anthropic-billing-header')) {
|
|
330
|
+
content = cleanCCUserContent(content);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
messages.push({ role: 'user', content });
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
let content = normalizeAnthropicContent(msg.content);
|
|
337
|
+
if (content.includes('<system-reminder>') || content.includes('x-anthropic-billing-header')) {
|
|
338
|
+
content = cleanCCUserContent(content);
|
|
339
|
+
}
|
|
340
|
+
messages.push({ role: 'user', content });
|
|
341
|
+
}
|
|
342
|
+
} else if (msg.role === 'assistant') {
|
|
343
|
+
if (Array.isArray(msg.content)) {
|
|
344
|
+
// 混合内容:文本 + 工具调用 + 思考
|
|
345
|
+
const textParts: string[] = [];
|
|
346
|
+
const toolCalls: any[] = [];
|
|
347
|
+
const reasoningParts: string[] = [];
|
|
348
|
+
for (const block of msg.content) {
|
|
349
|
+
if (block.type === 'text') {
|
|
350
|
+
textParts.push(block.text);
|
|
351
|
+
} else if (block.type === 'tool_use') {
|
|
352
|
+
toolCalls.push({
|
|
353
|
+
id: block.id,
|
|
354
|
+
type: 'function',
|
|
355
|
+
function: {
|
|
356
|
+
name: block.name,
|
|
357
|
+
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// thinking 块 → reasoning_content(DeepSeek 要求传回)
|
|
362
|
+
else if (block.type === 'thinking') {
|
|
363
|
+
reasoningParts.push(block.thinking || '');
|
|
364
|
+
}
|
|
365
|
+
// redacted_thinking 块:忽略
|
|
366
|
+
}
|
|
367
|
+
const msgObj: any = { role: 'assistant' };
|
|
368
|
+
// 只有文本时用文本,只有工具调用时 content 为 null(部分 API 要求显式 null)
|
|
369
|
+
if (textParts.length > 0) {
|
|
370
|
+
msgObj.content = textParts.join('\n');
|
|
371
|
+
} else if (toolCalls.length > 0) {
|
|
372
|
+
msgObj.content = null;
|
|
373
|
+
} else {
|
|
374
|
+
msgObj.content = '';
|
|
375
|
+
}
|
|
376
|
+
if (toolCalls.length > 0) msgObj.tool_calls = toolCalls;
|
|
377
|
+
// DeepSeek thinking 模式要求传回 reasoning_content
|
|
378
|
+
if (reasoningParts.length > 0) {
|
|
379
|
+
msgObj.reasoning_content = reasoningParts.join('');
|
|
380
|
+
}
|
|
381
|
+
messages.push(msgObj);
|
|
382
|
+
} else {
|
|
383
|
+
messages.push({ role: 'assistant', content: msg.content || '' });
|
|
384
|
+
}
|
|
385
|
+
} else if (msg.role === 'tool') {
|
|
386
|
+
const toolContent = (() => {
|
|
387
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
388
|
+
if (Array.isArray(msg.content)) return msg.content.map((b: any) => b.text || '').join('');
|
|
389
|
+
return String(msg.content || '');
|
|
390
|
+
})();
|
|
391
|
+
messages.push({
|
|
392
|
+
role: 'tool',
|
|
393
|
+
content: toolContent,
|
|
394
|
+
tool_call_id: msg.tool_use_id || '',
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// tool_choice 转换
|
|
400
|
+
let toolChoice: any = undefined;
|
|
401
|
+
if (anthropicBody.tool_choice) {
|
|
402
|
+
const tc = anthropicBody.tool_choice;
|
|
403
|
+
if (tc.type === 'any') toolChoice = 'required';
|
|
404
|
+
else if (tc.type === 'auto') toolChoice = 'auto';
|
|
405
|
+
else if (tc.type === 'tool' && tc.name) toolChoice = { type: 'function', function: { name: tc.name } };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 根据 Claude Code 传的模型名前缀,自动识别角色并替换
|
|
409
|
+
// 注意:modelName 参数在此上下文中是已解析的纯模型名(如 mimo-v2.5-pro)
|
|
410
|
+
// resolveModelByPrefix 仅用于首次路由,此处不二次调用
|
|
411
|
+
const resolvedModel = modelName;
|
|
412
|
+
|
|
413
|
+
// OpenCode 不保留 thinking 块,第二轮请求会因 reasoning_content 缺失被 DeepSeek 拒绝。
|
|
414
|
+
// 仅对 opencode-default 模型禁用 thinking。Claude Code 正常保留 thinking 不受影响。
|
|
415
|
+
const isOpenCodeModel = originalModelName === 'opencode-default';
|
|
416
|
+
const extraParams: any = {};
|
|
417
|
+
if (isOpenCodeModel) extraParams.thinking = { type: 'disabled' };
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
model: resolvedModel,
|
|
421
|
+
messages,
|
|
422
|
+
max_tokens: anthropicBody.max_tokens || 4096,
|
|
423
|
+
temperature: anthropicBody.temperature,
|
|
424
|
+
stream: anthropicBody.stream !== false,
|
|
425
|
+
...extraParams,
|
|
426
|
+
tools: anthropicBody.tools?.map((t: any) => {
|
|
427
|
+
// 修复 null/undefined input_schema,DeepSeek 等供应商不接受 type:null
|
|
428
|
+
let params: any = {};
|
|
429
|
+
if (t.input_schema && typeof t.input_schema === 'object') {
|
|
430
|
+
params = JSON.parse(JSON.stringify(t.input_schema, (k, v) => v === null ? undefined : v));
|
|
431
|
+
}
|
|
432
|
+
if (!params.type) params.type = 'object';
|
|
433
|
+
// web_search → web_search_20250305 (DeepSeek 版本化工具名)
|
|
434
|
+
const toolName = t.name === 'web_search' ? 'web_search_20250305' : t.name;
|
|
435
|
+
return {
|
|
436
|
+
type: 'function',
|
|
437
|
+
function: {
|
|
438
|
+
name: toolName,
|
|
439
|
+
description: t.description || '',
|
|
440
|
+
parameters: params,
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}),
|
|
444
|
+
...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** OpenAI 非流式响应 → Anthropic 格式 */
|
|
449
|
+
function openAIToAnthropic(openAIBody: any, modelName: string): any {
|
|
450
|
+
const choice = openAIBody.choices?.[0];
|
|
451
|
+
if (!choice) return { id: 'msg_err', type: 'message', role: 'assistant', content: [], model: modelName };
|
|
452
|
+
|
|
453
|
+
const content: any[] = [];
|
|
454
|
+
// DeepSeek reasoning_content → Anthropic thinking 块
|
|
455
|
+
if (choice.message?.reasoning_content) {
|
|
456
|
+
content.push({
|
|
457
|
+
type: 'thinking',
|
|
458
|
+
thinking: choice.message.reasoning_content,
|
|
459
|
+
signature: Buffer.from('cc-gw').toString('base64'),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (choice.message?.content) {
|
|
463
|
+
content.push({ type: 'text', text: choice.message.content });
|
|
464
|
+
}
|
|
465
|
+
if (choice.message?.tool_calls?.length > 0) {
|
|
466
|
+
for (const tc of choice.message.tool_calls) {
|
|
467
|
+
let input: any = {};
|
|
468
|
+
try { input = JSON.parse(tc.function?.arguments || '{}'); } catch { input = {}; }
|
|
469
|
+
content.push({
|
|
470
|
+
type: 'tool_use',
|
|
471
|
+
id: tc.id || `tool_${Date.now()}`,
|
|
472
|
+
name: tc.function?.name || '',
|
|
473
|
+
// 反向重命名 web_search_20250305 → WebSearch
|
|
474
|
+
...(tc.function?.name === 'web_search_20250305' ? { name: 'WebSearch' } : {}),
|
|
475
|
+
input,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const stopReason = choice.finish_reason === 'stop' ? 'end_turn'
|
|
481
|
+
: choice.finish_reason === 'tool_calls' ? 'tool_use'
|
|
482
|
+
: choice.finish_reason || 'end_turn';
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
id: `msg_${Date.now().toString(36)}`,
|
|
486
|
+
type: 'message',
|
|
487
|
+
role: 'assistant',
|
|
488
|
+
content,
|
|
489
|
+
model: modelName,
|
|
490
|
+
stop_reason: stopReason,
|
|
491
|
+
stop_sequence: null,
|
|
492
|
+
usage: openAIBody.usage || { input_tokens: 0, output_tokens: 0 },
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** OpenAI SSE 流 → Anthropic SSE 流 */
|
|
497
|
+
function openAIStreamToAnthropic(openAIStream: NodeJS.ReadableStream, res: http.ServerResponse, modelName: string, _reqBody?: any): void {
|
|
498
|
+
let buffer = '';
|
|
499
|
+
const messageId = `msg_${Date.now().toString(36)}`;
|
|
500
|
+
let sentMessageStart = false;
|
|
501
|
+
let currentBlockIndex = -1;
|
|
502
|
+
let currentBlockType: 'text' | 'tool_use' | null = null;
|
|
503
|
+
let toolUseId = '';
|
|
504
|
+
let toolUseName = '';
|
|
505
|
+
let textStarted = false;
|
|
506
|
+
let toolUseStarted = false;
|
|
507
|
+
let lastFinishReason = 'end_turn';
|
|
508
|
+
let cachedReasoningContent = '';
|
|
509
|
+
|
|
510
|
+
function sendEvent(eventType: string, data: any) {
|
|
511
|
+
res.write(`event: ${eventType}\n`);
|
|
512
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function sendMessageStart() {
|
|
516
|
+
if (sentMessageStart) return;
|
|
517
|
+
sentMessageStart = true;
|
|
518
|
+
sendEvent('message_start', {
|
|
519
|
+
type: 'message_start',
|
|
520
|
+
message: {
|
|
521
|
+
id: messageId, type: 'message', role: 'assistant',
|
|
522
|
+
content: [], model: modelName, stop_reason: null,
|
|
523
|
+
stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 },
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function startTextBlock() {
|
|
529
|
+
if (textStarted) return;
|
|
530
|
+
// 如果之前在 tool_use,先结束它
|
|
531
|
+
if (toolUseStarted) {
|
|
532
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
533
|
+
toolUseStarted = false;
|
|
534
|
+
}
|
|
535
|
+
sendMessageStart();
|
|
536
|
+
currentBlockIndex++;
|
|
537
|
+
currentBlockType = 'text';
|
|
538
|
+
textStarted = true;
|
|
539
|
+
sendEvent('content_block_start', {
|
|
540
|
+
type: 'content_block_start',
|
|
541
|
+
index: currentBlockIndex,
|
|
542
|
+
content_block: { type: 'text', text: '' },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function startToolUseBlock(id: string, name: string) {
|
|
547
|
+
if (toolUseStarted && toolUseId === id) return;
|
|
548
|
+
if (textStarted) {
|
|
549
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
550
|
+
textStarted = false;
|
|
551
|
+
}
|
|
552
|
+
if (toolUseStarted) {
|
|
553
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
554
|
+
}
|
|
555
|
+
sendMessageStart();
|
|
556
|
+
currentBlockIndex++;
|
|
557
|
+
currentBlockType = 'tool_use';
|
|
558
|
+
toolUseStarted = true;
|
|
559
|
+
toolUseId = id;
|
|
560
|
+
toolUseName = name;
|
|
561
|
+
sendEvent('content_block_start', {
|
|
562
|
+
type: 'content_block_start',
|
|
563
|
+
index: currentBlockIndex,
|
|
564
|
+
content_block: { type: 'tool_use', id, name },
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function finishStream() {
|
|
569
|
+
if (textStarted || toolUseStarted) {
|
|
570
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
571
|
+
}
|
|
572
|
+
// 缓存 reasoning_content 到全局(供下游第二轮请求使用)
|
|
573
|
+
if (cachedReasoningContent && _reqBody?.messages) {
|
|
574
|
+
const fp = conversationFingerprint(_reqBody.messages);
|
|
575
|
+
if (fp) {
|
|
576
|
+
reasoningCache.set(fp, cachedReasoningContent);
|
|
577
|
+
console.log(`[Proxy] 🧠 reasoning_content 已缓存(指纹: ${fp.slice(0, 50)}...)`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
sendEvent('message_delta', {
|
|
581
|
+
type: 'message_delta',
|
|
582
|
+
delta: { stop_reason: lastFinishReason, stop_sequence: null },
|
|
583
|
+
usage: { output_tokens: 0 },
|
|
584
|
+
});
|
|
585
|
+
sendEvent('message_stop', { type: 'message_stop' });
|
|
586
|
+
res.end();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
openAIStream.on('data', (chunk: Buffer) => {
|
|
590
|
+
buffer += chunk.toString();
|
|
591
|
+
while (buffer.includes('\n')) {
|
|
592
|
+
const lineEnd = buffer.indexOf('\n');
|
|
593
|
+
const line = buffer.slice(0, lineEnd).trim();
|
|
594
|
+
buffer = buffer.slice(lineEnd + 1);
|
|
595
|
+
|
|
596
|
+
if (!line.startsWith('data:')) continue;
|
|
597
|
+
const dataStr = line.slice(5).trim();
|
|
598
|
+
if (dataStr === '[DONE]') {
|
|
599
|
+
finishStream();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let json: any;
|
|
604
|
+
try { json = JSON.parse(dataStr); } catch { continue; }
|
|
605
|
+
|
|
606
|
+
const delta = json.choices?.[0]?.delta;
|
|
607
|
+
// 跟踪 finish_reason — 移到 delta 判断外面,因为最终 chunk 可能有 finish_reason 但无 delta
|
|
608
|
+
const fr = json.choices?.[0]?.finish_reason;
|
|
609
|
+
if (fr === 'tool_calls') lastFinishReason = 'tool_use';
|
|
610
|
+
else if (fr === 'stop') lastFinishReason = 'end_turn';
|
|
611
|
+
if (!delta) continue;
|
|
612
|
+
|
|
613
|
+
// reasoning_content → Anthropic thinking 块 + 缓存(用于下游第二轮请求)
|
|
614
|
+
if (delta.reasoning_content) {
|
|
615
|
+
cachedReasoningContent += delta.reasoning_content;
|
|
616
|
+
// 结束当前块再开 thinking 块
|
|
617
|
+
if (textStarted) {
|
|
618
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
619
|
+
textStarted = false;
|
|
620
|
+
}
|
|
621
|
+
if (toolUseStarted) {
|
|
622
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
623
|
+
toolUseStarted = false;
|
|
624
|
+
}
|
|
625
|
+
sendMessageStart();
|
|
626
|
+
currentBlockIndex++;
|
|
627
|
+
sendEvent('content_block_start', {
|
|
628
|
+
type: 'content_block_start',
|
|
629
|
+
index: currentBlockIndex,
|
|
630
|
+
content_block: { type: 'thinking', thinking: '', signature: Buffer.from('cc-gw').toString('base64') },
|
|
631
|
+
});
|
|
632
|
+
sendEvent('content_block_delta', {
|
|
633
|
+
type: 'content_block_delta',
|
|
634
|
+
index: currentBlockIndex,
|
|
635
|
+
delta: { type: 'thinking_delta', thinking: delta.reasoning_content },
|
|
636
|
+
});
|
|
637
|
+
// thinking 块立即结束(非流式累积)
|
|
638
|
+
sendEvent('content_block_stop', { type: 'content_block_stop', index: currentBlockIndex });
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 文本增量
|
|
643
|
+
if (delta.content) {
|
|
644
|
+
startTextBlock();
|
|
645
|
+
sendEvent('content_block_delta', {
|
|
646
|
+
type: 'content_block_delta',
|
|
647
|
+
index: currentBlockIndex,
|
|
648
|
+
delta: { type: 'text_delta', text: delta.content },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// 工具调用增量
|
|
653
|
+
if (delta.tool_calls?.length > 0) {
|
|
654
|
+
for (const tc of delta.tool_calls) {
|
|
655
|
+
const tcId = tc.id || `tool_${Date.now()}`;
|
|
656
|
+
const tcName = tc.function?.name || '';
|
|
657
|
+
// 反向重命名 web_search_20250305 → WebSearch(恢复 Claude Code 原名)
|
|
658
|
+
const resolvedName = tcName === 'web_search_20250305' ? 'WebSearch' : tcName;
|
|
659
|
+
if (resolvedName !== tcName) tc.function.name = resolvedName;
|
|
660
|
+
if (resolvedName) {
|
|
661
|
+
startToolUseBlock(tcId, resolvedName);
|
|
662
|
+
}
|
|
663
|
+
if (tc.function?.arguments) {
|
|
664
|
+
sendEvent('content_block_delta', {
|
|
665
|
+
type: 'content_block_delta',
|
|
666
|
+
index: currentBlockIndex,
|
|
667
|
+
delta: { type: 'input_json_delta', partial_json: tc.function.arguments },
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
openAIStream.on('end', () => {
|
|
676
|
+
if (!res.writableEnded) {
|
|
677
|
+
finishStream();
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
openAIStream.on('error', (err) => {
|
|
682
|
+
console.error(`[Proxy] OpenAI 流错误: ${err.message}`);
|
|
683
|
+
if (!res.writableEnded) {
|
|
684
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
685
|
+
res.end(JSON.stringify({ error: `Stream error: ${err.message}`, type: 'api_error' }));
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ===== 费用计算 =====
|
|
691
|
+
export function calculateCost(modelSpec: string, inputTokens: number, outputTokens: number): number {
|
|
692
|
+
try {
|
|
693
|
+
const provider = modelSpec.split('/')[0];
|
|
694
|
+
const providers = loadProviders().providers;
|
|
695
|
+
const cfg = providers.get(provider);
|
|
696
|
+
if (cfg?.pricing) {
|
|
697
|
+
const p = cfg.pricing;
|
|
698
|
+
return (inputTokens * p.inputPerMillion + outputTokens * p.outputPerMillion) / 1_000_000;
|
|
699
|
+
}
|
|
700
|
+
// 未知供应商 — 输出警告日志
|
|
701
|
+
console.warn(`[calculateCost] ⚠️ 未知供应商 "${provider}",使用默认价格 ($0.55/M input, $2.19/M output)`);
|
|
702
|
+
} catch {}
|
|
703
|
+
return (inputTokens * 0.55 + outputTokens * 2.19) / 1_000_000;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ===== HTTP 代理服务器 =====
|
|
707
|
+
let server: http.Server | null = null;
|
|
708
|
+
const REQUEST_TIMEOUT = 120_000; // 120 秒
|
|
709
|
+
|
|
710
|
+
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
711
|
+
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
712
|
+
const reqPath = reqUrl.pathname;
|
|
713
|
+
if (reqPath.includes('response') || reqPath.includes('/v1/')) {
|
|
714
|
+
require('fs').appendFileSync('/tmp/imtoagent-paths.log', req.method + ' ' + reqPath + '\n');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Codex 路径 → 转发到 codex handler
|
|
718
|
+
if (reqPath === '/v1/responses' || reqPath.startsWith('/v1/responses')) {
|
|
719
|
+
handleCodexDispatch(req, res);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const cfg = sharedState.activeConfig;
|
|
724
|
+
if (!cfg) {
|
|
725
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
726
|
+
res.end(JSON.stringify({ error: 'No active provider configured. Use /model to set one.' }));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const isAnthropicFormat = cfg.format === 'anthropic';
|
|
731
|
+
const urlObj = new URL(cfg.baseUrl);
|
|
732
|
+
const upstreamHost = urlObj.host;
|
|
733
|
+
const upstreamProto = urlObj.protocol.replace(':', '');
|
|
734
|
+
const basePath = urlObj.pathname.replace(/\/+$/, ''); // 去掉尾部斜杠
|
|
735
|
+
|
|
736
|
+
// 处理 /v1/models 请求:返回当前供应商的模型列表(Claude Code SDK 会调用)
|
|
737
|
+
if (reqPath === '/v1/models' && req.method === 'GET') {
|
|
738
|
+
const modelList = providers.get(cfg.providerName)?.models || [cfg.model];
|
|
739
|
+
const response = {
|
|
740
|
+
object: 'list',
|
|
741
|
+
data: modelList.map((m: string) => ({
|
|
742
|
+
id: m,
|
|
743
|
+
object: 'model',
|
|
744
|
+
created: Date.now(),
|
|
745
|
+
owned_by: cfg.providerName,
|
|
746
|
+
})),
|
|
747
|
+
};
|
|
748
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
749
|
+
res.end(JSON.stringify(response));
|
|
750
|
+
console.log(`[Proxy] → ${cfg.providerName}/${cfg.model} GET /v1/models (模拟返回 ${modelList.length} 个模型)`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// 处理 /health 请求
|
|
755
|
+
if (reqPath === '/health' && req.method === 'GET') {
|
|
756
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
757
|
+
const modelSpec = cfg ? `${cfg.providerName}/${cfg.model}` : 'none';
|
|
758
|
+
res.end(JSON.stringify({ status: 'ok', model: modelSpec, providers: [...providers.keys()] }));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// 拦截 HEAD 请求,避免转发到不支持 HEAD 的上游(mimo2api 等只接受 POST)
|
|
763
|
+
if (req.method === 'HEAD') {
|
|
764
|
+
res.writeHead(200);
|
|
765
|
+
res.end();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// 根据供应商格式决定上游路径(仅限 /v1/messages)
|
|
770
|
+
const upstreamPath = isAnthropicFormat
|
|
771
|
+
? `${basePath}/v1/messages`
|
|
772
|
+
: `${basePath}/chat/completions`;
|
|
773
|
+
|
|
774
|
+
// 收集请求体
|
|
775
|
+
const chunks: Buffer[] = [];
|
|
776
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
777
|
+
req.on('end', () => {
|
|
778
|
+
let bodyStr = Buffer.concat(chunks).toString('utf-8');
|
|
779
|
+
let originalModel = '';
|
|
780
|
+
let parsedBody: any = {};
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
parsedBody = JSON.parse(bodyStr);
|
|
784
|
+
originalModel = parsedBody.model || '';
|
|
785
|
+
} catch { /* 非 JSON */ }
|
|
786
|
+
|
|
787
|
+
// 根据 Claude Code 传的模型名前缀,识别角色并替换为完整规格
|
|
788
|
+
const resolvedSpec = resolveModelByPrefix(originalModel);
|
|
789
|
+
console.log(`[Proxy] 模型解析: ${originalModel} → ${resolvedSpec}`);
|
|
790
|
+
|
|
791
|
+
// 🌐 Web Search:DeepSeek 使用版本化工具名 web_search_20250305,Claude Code 发的是 web_search
|
|
792
|
+
if (parsedBody.tools) {
|
|
793
|
+
for (const t of parsedBody.tools) {
|
|
794
|
+
const tn = (t.name || t.type || '').toLowerCase();
|
|
795
|
+
// 调试:WebSearch 工具的完整定义
|
|
796
|
+
if ((t.name || '').toLowerCase().includes('search')) {
|
|
797
|
+
console.log(`[Proxy] 🔍 WebSearch 原始定义: ${JSON.stringify(t)}`);
|
|
798
|
+
}
|
|
799
|
+
if (tn === 'web_search' || tn === 'websearch') {
|
|
800
|
+
t.name = 'web_search_20250305';
|
|
801
|
+
console.log(`[Proxy] 🔍 web_search → web_search_20250305 ✅`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (parsedBody.tools && parsedBody.tools.length > 0) {
|
|
806
|
+
console.log(`[Proxy] 🔍 tools 定义: ${parsedBody.tools.map((t: any) => t.name || t.type).join(', ')}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// 解析供应商和模型名
|
|
810
|
+
const slashIdx = resolvedSpec.indexOf('/');
|
|
811
|
+
let targetProviderName: string;
|
|
812
|
+
let targetModelName: string;
|
|
813
|
+
|
|
814
|
+
if (slashIdx >= 0) {
|
|
815
|
+
targetProviderName = resolvedSpec.slice(0, slashIdx);
|
|
816
|
+
targetModelName = resolvedSpec.slice(slashIdx + 1);
|
|
817
|
+
} else {
|
|
818
|
+
// 无供应商前缀,使用当前配置
|
|
819
|
+
targetProviderName = cfg.providerName;
|
|
820
|
+
targetModelName = resolvedSpec;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// 根据目标供应商获取配置
|
|
824
|
+
const targetProvider = providers.get(targetProviderName);
|
|
825
|
+
if (!targetProvider) {
|
|
826
|
+
console.error(`[Proxy] 未知供应商: ${targetProviderName}`);
|
|
827
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
828
|
+
res.end(JSON.stringify({ error: `Unknown provider: ${targetProviderName}` }));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const targetIsAnthropic = targetProvider.format === 'anthropic';
|
|
833
|
+
const targetUrlObj = new URL(targetProvider.baseUrl);
|
|
834
|
+
const targetUpstreamHost = targetUrlObj.host;
|
|
835
|
+
const targetUpstreamProto = targetUrlObj.protocol.replace(':', '');
|
|
836
|
+
const targetBasePath = targetUrlObj.pathname.replace(/\/+$/, '');
|
|
837
|
+
const targetUpstreamPath = targetIsAnthropic
|
|
838
|
+
? `${targetBasePath}/v1/messages`
|
|
839
|
+
: `${targetBasePath}/chat/completions`;
|
|
840
|
+
|
|
841
|
+
// 格式转换
|
|
842
|
+
let finalBody: string;
|
|
843
|
+
if (targetIsAnthropic) {
|
|
844
|
+
parsedBody.model = targetModelName;
|
|
845
|
+
finalBody = JSON.stringify(parsedBody);
|
|
846
|
+
} else {
|
|
847
|
+
// Bug 3 修复:检查 assistant 消息是否缺少 thinking 块,从缓存注入
|
|
848
|
+
if (parsedBody.messages && Array.isArray(parsedBody.messages)) {
|
|
849
|
+
const fp = conversationFingerprint(parsedBody.messages);
|
|
850
|
+
const cached = fp ? reasoningCache.get(fp) : null;
|
|
851
|
+
if (cached) {
|
|
852
|
+
for (const msg of parsedBody.messages) {
|
|
853
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
854
|
+
const hasThinking = msg.content.some((b: any) => b.type === 'thinking' || b.type === 'redacted_thinking');
|
|
855
|
+
if (!hasThinking) {
|
|
856
|
+
msg.content.unshift({
|
|
857
|
+
type: 'thinking',
|
|
858
|
+
thinking: cached,
|
|
859
|
+
signature: Buffer.from('cc-gw').toString('base64'),
|
|
860
|
+
});
|
|
861
|
+
console.log(`[Proxy] 🧠 注入 thinking 块(缓存命中,长度: ${cached.length})`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
parsedBody = anthropicToOpenAI(parsedBody, targetModelName, originalModel);
|
|
868
|
+
finalBody = JSON.stringify(parsedBody);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const options: http.RequestOptions = {
|
|
872
|
+
hostname: targetUrlObj.hostname,
|
|
873
|
+
port: targetUrlObj.port || (targetUpstreamProto === 'https' ? 443 : 80),
|
|
874
|
+
path: targetUpstreamPath,
|
|
875
|
+
method: req.method,
|
|
876
|
+
headers: {
|
|
877
|
+
'Content-Type': 'application/json',
|
|
878
|
+
'Content-Length': Buffer.byteLength(finalBody),
|
|
879
|
+
...(targetIsAnthropic
|
|
880
|
+
? { 'x-api-key': targetProvider.apiKey, 'anthropic-version': '2023-06-01' }
|
|
881
|
+
: { Authorization: `Bearer ${targetProvider.apiKey}` }),
|
|
882
|
+
},
|
|
883
|
+
timeout: REQUEST_TIMEOUT,
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const isStream = parsedBody.stream !== false;
|
|
887
|
+
|
|
888
|
+
console.log(`[Proxy] → ${targetProviderName}/${targetModelName} (${targetProvider.format}) ${req.method} ${req.url}${originalModel ? ` (原: ${originalModel})` : ''}`);
|
|
889
|
+
|
|
890
|
+
const upstreamReq = (targetUpstreamProto === 'https' ? require('https') : http).request(options, (upstreamRes) => {
|
|
891
|
+
// 流式响应
|
|
892
|
+
if (isStream && upstreamRes.headers['content-type']?.includes('text/event-stream')) {
|
|
893
|
+
res.writeHead(200, {
|
|
894
|
+
'Content-Type': 'text/event-stream',
|
|
895
|
+
'Cache-Control': 'no-cache',
|
|
896
|
+
Connection: 'keep-alive',
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
if (targetIsAnthropic) {
|
|
900
|
+
// Anthropic 供应商:透传 SSE 流,但重写 message_start 中的 model 名
|
|
901
|
+
let streamBuffer = '';
|
|
902
|
+
const modelNameForStream = originalModel || targetModelName;
|
|
903
|
+
upstreamRes.on('data', (chunk: Buffer) => {
|
|
904
|
+
if (!res.writableEnded) {
|
|
905
|
+
streamBuffer += chunk.toString();
|
|
906
|
+
// 重写 message_start 事件中的 model 字段
|
|
907
|
+
while (streamBuffer.includes('\n')) {
|
|
908
|
+
const idx = streamBuffer.indexOf('\n');
|
|
909
|
+
const line = streamBuffer.slice(0, idx).trim();
|
|
910
|
+
streamBuffer = streamBuffer.slice(idx + 1);
|
|
911
|
+
if (line.startsWith('data:') && line.includes('"message_start"') && line.includes('"model"')) {
|
|
912
|
+
try {
|
|
913
|
+
const jsonStr = line.slice(5).trim();
|
|
914
|
+
const evt = JSON.parse(jsonStr);
|
|
915
|
+
if (evt.message) evt.message.model = modelNameForStream;
|
|
916
|
+
res.write(`data: ${JSON.stringify(evt)}\n\n`);
|
|
917
|
+
} catch { res.write(line + '\n'); }
|
|
918
|
+
} else {
|
|
919
|
+
res.write(line + '\n');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
upstreamRes.on('end', () => { if (!res.writableEnded) res.end(); });
|
|
925
|
+
upstreamRes.on('error', (err) => { if (!res.writableEnded) { res.writeHead(502); res.end(); } });
|
|
926
|
+
} else {
|
|
927
|
+
// OpenAI 供应商:转换为 Anthropic 格式
|
|
928
|
+
openAIStreamToAnthropic(upstreamRes, res, originalModel || targetModelName);
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
// 非流式响应
|
|
932
|
+
const respChunks: Buffer[] = [];
|
|
933
|
+
upstreamRes.on('data', (chunk: Buffer) => respChunks.push(chunk));
|
|
934
|
+
upstreamRes.on('end', () => {
|
|
935
|
+
const respStr = Buffer.concat(respChunks).toString('utf-8');
|
|
936
|
+
|
|
937
|
+
if (upstreamRes.statusCode !== 200) {
|
|
938
|
+
console.error(`[Proxy] 上游错误 ${upstreamRes.statusCode}: ${respStr.slice(0, 500)}`);
|
|
939
|
+
res.writeHead(upstreamRes.statusCode || 500, { 'Content-Type': 'application/json' });
|
|
940
|
+
res.end(respStr);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (targetIsAnthropic) {
|
|
945
|
+
// 重写 model 名为 CC 原始名(上游返回 mimo-v2.5-pro,CC 不认识)
|
|
946
|
+
try {
|
|
947
|
+
let uj = JSON.parse(respStr);
|
|
948
|
+
uj.model = originalModel || uj.model;
|
|
949
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
950
|
+
res.end(JSON.stringify(uj));
|
|
951
|
+
} catch {
|
|
952
|
+
res.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
|
953
|
+
res.end(respStr);
|
|
954
|
+
}
|
|
955
|
+
} else {
|
|
956
|
+
let openAIJson: any;
|
|
957
|
+
try { openAIJson = JSON.parse(respStr); } catch {
|
|
958
|
+
console.error(`[Proxy] OpenAI JSON 解析失败: ${respStr.slice(0, 200)}`);
|
|
959
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
960
|
+
res.end(JSON.stringify({ error: 'Invalid upstream response', type: 'api_error' }));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const anthropicResp = openAIToAnthropic(openAIJson, originalModel || targetModelName);
|
|
964
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
965
|
+
res.end(JSON.stringify(anthropicResp));
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
upstreamReq.on('timeout', () => {
|
|
972
|
+
upstreamReq.destroy();
|
|
973
|
+
console.error(`[Proxy] 请求超时 (${REQUEST_TIMEOUT}ms)`);
|
|
974
|
+
if (!res.writableEnded) {
|
|
975
|
+
res.writeHead(504, { 'Content-Type': 'application/json' });
|
|
976
|
+
res.end(JSON.stringify({ error: 'Upstream request timeout', type: 'api_error' }));
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
upstreamReq.on('error', (err) => {
|
|
981
|
+
console.error(`[Proxy] 上游请求失败: ${err.message}`);
|
|
982
|
+
if (!res.writableEnded) {
|
|
983
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
984
|
+
res.end(JSON.stringify({ error: `Upstream error: ${err.message}`, type: 'api_error' }));
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
upstreamReq.end(finalBody);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** 将 Node req/res 桥接到 codex handler */
|
|
993
|
+
async function handleCodexDispatch(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
994
|
+
const chunks: Buffer[] = [];
|
|
995
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
996
|
+
req.on('end', async () => {
|
|
997
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
998
|
+
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
999
|
+
await handleCodexRequest(body, reqUrl.pathname, req.method || 'GET', res);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
export function startAnthropicProxy(port = 18899): Promise<number> {
|
|
1003
|
+
/** 将 Node req 桥接到 codex handler(handler 直接操作 res) */
|
|
1004
|
+
return new Promise((resolve) => {
|
|
1005
|
+
server = http.createServer(handleRequest);
|
|
1006
|
+
server.listen(port, () => {
|
|
1007
|
+
console.log(`[Proxy] 本地代理启动 http://localhost:${port}/v1/messages`);
|
|
1008
|
+
console.log(`[Proxy] 当前模型: ${sharedState.activeConfig ? `${sharedState.activeConfig.providerName}/${sharedState.activeConfig.model} (${sharedState.activeConfig.format})` : '未设置'}`);
|
|
1009
|
+
console.log(`[Proxy] 供应商: ${sharedState.activeConfig?.baseUrl || '无'}`);
|
|
1010
|
+
resolve(port);
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function stopAnthropicProxy(): Promise<void> {
|
|
1016
|
+
return new Promise((resolve) => {
|
|
1017
|
+
if (server) {
|
|
1018
|
+
server.close(() => {
|
|
1019
|
+
console.log('[Proxy] 代理服务器已关闭');
|
|
1020
|
+
server = null;
|
|
1021
|
+
resolve();
|
|
1022
|
+
});
|
|
1023
|
+
} else {
|
|
1024
|
+
resolve();
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ===== 会话持久化 =====
|
|
1030
|
+
export interface SessionMemoryData {
|
|
1031
|
+
chatId: string;
|
|
1032
|
+
userId: string;
|
|
1033
|
+
sdkSessionId?: string;
|
|
1034
|
+
cwd?: string;
|
|
1035
|
+
permissionMode?: string;
|
|
1036
|
+
recentMessages: string[];
|
|
1037
|
+
stats: { calls: number; totalTurns: number; totalInputTokens: number; totalOutputTokens: number; totalCostUSD: number; totalDurationMs: number };
|
|
1038
|
+
activeModel: string;
|
|
1039
|
+
modelAliases: ModelAliases;
|
|
1040
|
+
lastUsed: number;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export function saveSessionMemory(memoryPath: string, data: SessionMemoryData): void {
|
|
1044
|
+
const dir = path.dirname(memoryPath);
|
|
1045
|
+
try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
1046
|
+
try {
|
|
1047
|
+
fs.writeFileSync(memoryPath, JSON.stringify(data, null, 2));
|
|
1048
|
+
} catch (e: any) {
|
|
1049
|
+
console.error(`[Memory] 保存会话失败: ${e.message}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export function loadSessionMemory(memoryPath: string): SessionMemoryData | null {
|
|
1054
|
+
try {
|
|
1055
|
+
if (!fs.existsSync(memoryPath)) return null;
|
|
1056
|
+
return JSON.parse(fs.readFileSync(memoryPath, 'utf-8'));
|
|
1057
|
+
} catch (e: any) {
|
|
1058
|
+
console.error(`[Memory] 加载会话失败: ${e.message}`);
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export function deleteSessionMemory(chatId: string): void {
|
|
1064
|
+
const filePath = `${SESSIONS_DIR()}/${chatId}.memory.json`;
|
|
1065
|
+
try {
|
|
1066
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
1067
|
+
} catch (e: any) {
|
|
1068
|
+
console.error(`[Memory] 删除会话 ${chatId} 失败: ${e.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export function listPersistedSessions(): string[] {
|
|
1073
|
+
try {
|
|
1074
|
+
const sessionsBase = SESSIONS_DIR();
|
|
1075
|
+
if (!fs.existsSync(sessionsBase)) return [];
|
|
1076
|
+
return fs.readdirSync(sessionsBase)
|
|
1077
|
+
.filter(f => f.endsWith('.memory.json'))
|
|
1078
|
+
.map(f => f.replace('.memory.json', ''));
|
|
1079
|
+
} catch (e: any) {
|
|
1080
|
+
console.error(`[Memory] 扫描会话目录失败: ${e.message}`);
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
}
|