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,657 @@
|
|
|
1
|
+
// Codex Proxy — Responses API ↔ Chat Completions 双向转换
|
|
2
|
+
// Codex 请求处理器(已合并到 18899) · 可作为模块导入或被 Bun 直接运行
|
|
3
|
+
|
|
4
|
+
import { getCurrentBot } from '../bot-context';
|
|
5
|
+
import { buildSystemPrompt, resolveCapabilities, DEFAULT_TERMINAL_CAPS } from '../prompt-builder';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import { getDataDir } from '../utils/paths';
|
|
9
|
+
|
|
10
|
+
// ================================================================
|
|
11
|
+
// 配置(从 config.json 读取,不再硬编码)
|
|
12
|
+
// ================================================================
|
|
13
|
+
interface CodexProxyConfig {
|
|
14
|
+
model: string;
|
|
15
|
+
reportedModel: string;
|
|
16
|
+
upstream: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let _codexConfig: CodexProxyConfig | null = null;
|
|
21
|
+
|
|
22
|
+
export function initCodexProxyConfig(cfg: CodexProxyConfig) {
|
|
23
|
+
_codexConfig = cfg;
|
|
24
|
+
console.log(`[Codex Proxy] 配置已加载: model=${cfg.model}, upstream=${cfg.upstream}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getConfig(): CodexProxyConfig {
|
|
28
|
+
if (!_codexConfig) {
|
|
29
|
+
// Fallback: 尝试从 config.json 读取
|
|
30
|
+
try {
|
|
31
|
+
|
|
32
|
+
const configPath = path.join(getDataDir(), 'config.json');
|
|
33
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
34
|
+
const codex = raw.codex || {};
|
|
35
|
+
const providers = raw.providers || {};
|
|
36
|
+
let apiKey = '';
|
|
37
|
+
for (const name of Object.keys(providers)) {
|
|
38
|
+
apiKey = providers[name].apiKey || '';
|
|
39
|
+
if (apiKey) break;
|
|
40
|
+
}
|
|
41
|
+
_codexConfig = {
|
|
42
|
+
model: codex.model || 'deepseek-v4-pro',
|
|
43
|
+
reportedModel: codex.reportedModel || 'gpt-5.5',
|
|
44
|
+
upstream: codex.upstream || 'https://api.deepseek.com/v1/chat/completions',
|
|
45
|
+
apiKey,
|
|
46
|
+
};
|
|
47
|
+
console.log('[Codex Proxy] 从 config.json 加载配置');
|
|
48
|
+
} catch (e: any) {
|
|
49
|
+
console.error(`[Codex Proxy] 无法加载配置: ${e.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return _codexConfig!;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MODEL = () => getConfig().model;
|
|
56
|
+
const REPORTED_MODEL = () => getConfig().reportedModel;
|
|
57
|
+
const UPSTREAM = () => getConfig().upstream;
|
|
58
|
+
const API_KEY = () => getConfig().apiKey;
|
|
59
|
+
|
|
60
|
+
// ================================================================
|
|
61
|
+
// 类型
|
|
62
|
+
// ================================================================
|
|
63
|
+
|
|
64
|
+
interface ChatMessage {
|
|
65
|
+
role: string;
|
|
66
|
+
content: string | null;
|
|
67
|
+
tool_calls?: ToolCall[];
|
|
68
|
+
tool_call_id?: string;
|
|
69
|
+
reasoning_content?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ToolCall {
|
|
73
|
+
id: string;
|
|
74
|
+
type: 'function';
|
|
75
|
+
function: { name: string; arguments: string };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PendingToolCall {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
arguments: string;
|
|
82
|
+
outputIndex: number;
|
|
83
|
+
itemId: string;
|
|
84
|
+
started: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ResponseItem {
|
|
88
|
+
id: string;
|
|
89
|
+
type: string;
|
|
90
|
+
[key: string]: any;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ================================================================
|
|
94
|
+
// 1. 请求翻译: Responses → Chat Completions
|
|
95
|
+
// ================================================================
|
|
96
|
+
function responsesToChat(body: any): { model: string; messages: ChatMessage[]; stream: boolean; max_tokens?: number; tools?: any[] } {
|
|
97
|
+
const chat: { model: string; messages: ChatMessage[]; stream: boolean; max_tokens?: number; tools?: any[]; thinking?: { type: string } } = {
|
|
98
|
+
model: MODEL(),
|
|
99
|
+
messages: [],
|
|
100
|
+
stream: true,
|
|
101
|
+
thinking: { type: 'disabled' }, // Codex 不兼容 thinking 模式,content 全 null 导致流断开
|
|
102
|
+
};
|
|
103
|
+
chat.max_tokens = body.max_output_tokens || 8192;
|
|
104
|
+
|
|
105
|
+
// 工具转换
|
|
106
|
+
if (body.tools?.length) {
|
|
107
|
+
const allNames = body.tools.map((t: any) => t.name || t.function?.name).filter((n: string) => n && n.length > 0).join(', ');
|
|
108
|
+
console.log(`[Codex] tools: ${allNames}`);
|
|
109
|
+
chat.tools = body.tools
|
|
110
|
+
.map((t: any) => {
|
|
111
|
+
if (t.function) return t;
|
|
112
|
+
const p = JSON.parse(JSON.stringify(t.parameters || {}, (_: string, v: any) => v === null ? undefined : v));
|
|
113
|
+
if (!p.type) p.type = 'object';
|
|
114
|
+
return { type: 'function', function: { name: t.name || '', description: t.description || '', parameters: p } };
|
|
115
|
+
})
|
|
116
|
+
.filter((t: any) => t.function?.name && t.function.name.length > 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 消息转换
|
|
120
|
+
let input: any[] = body.input || [];
|
|
121
|
+
if (input.length > 0) {
|
|
122
|
+
// 防护:输入历史过长时截断,防止 tool call loop 导致 OOM
|
|
123
|
+
const MAX_INPUT_ITEMS = 120;
|
|
124
|
+
if (input.length > MAX_INPUT_ITEMS) {
|
|
125
|
+
const truncated = input.length - MAX_INPUT_ITEMS;
|
|
126
|
+
// 保留 system 消息(如果有)+ 最近 MAX_INPUT_ITEMS 条
|
|
127
|
+
const systemItems = input.filter((m: any) => m.role === 'system' || m.role === 'developer');
|
|
128
|
+
const nonSystem = input.filter((m: any) => m.role !== 'system' && m.role !== 'developer');
|
|
129
|
+
const kept = nonSystem.slice(-(MAX_INPUT_ITEMS - systemItems.length));
|
|
130
|
+
input = [...systemItems, ...kept];
|
|
131
|
+
console.log(`[Codex] ⚠️ 截断输入: ${input.length + truncated} → ${input.length} 条 (丢弃最旧 ${truncated} 条)`);
|
|
132
|
+
}
|
|
133
|
+
const types = input.map((m: any) => m.type || ('msg:' + m.role)).join(',');
|
|
134
|
+
console.log(`[Codex] input types: [${types}]`);
|
|
135
|
+
console.log(`[Codex] input items: ${input.length}`);
|
|
136
|
+
}
|
|
137
|
+
let pendingReasoning = '';
|
|
138
|
+
let i = 0;
|
|
139
|
+
|
|
140
|
+
while (i < input.length) {
|
|
141
|
+
const msg = input[i];
|
|
142
|
+
|
|
143
|
+
if (msg.type === 'reasoning') {
|
|
144
|
+
const summary = msg.summary || [];
|
|
145
|
+
pendingReasoning = summary.map((s: any) => s.text || s.summary_text || '').join('').trim();
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (msg.type === 'function_call') {
|
|
151
|
+
const tc: ToolCall = {
|
|
152
|
+
id: msg.call_id || '',
|
|
153
|
+
type: 'function',
|
|
154
|
+
function: { name: msg.name || '', arguments: msg.arguments || '{}' },
|
|
155
|
+
};
|
|
156
|
+
const lastMsg = chat.messages[chat.messages.length - 1];
|
|
157
|
+
// Only append to previous assistant if it already has tool_calls (multi-call in one turn)
|
|
158
|
+
// Do NOT append to assistant that has text content — that breaks tool_call/tool pairing
|
|
159
|
+
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.tool_calls?.length) {
|
|
160
|
+
lastMsg.tool_calls.push(tc);
|
|
161
|
+
} else {
|
|
162
|
+
const asstMsg: ChatMessage = { role: 'assistant', content: null, tool_calls: [tc] };
|
|
163
|
+
if (pendingReasoning) { asstMsg.reasoning_content = pendingReasoning; pendingReasoning = ''; }
|
|
164
|
+
chat.messages.push(asstMsg);
|
|
165
|
+
}
|
|
166
|
+
i++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (msg.type === 'function_call_output') {
|
|
171
|
+
// CRITICAL: tool_call_id must exactly match the tool_call.id from the assistant message
|
|
172
|
+
// Some upstreams send call_id without 'call_' prefix, some with — keep it exactly as-is
|
|
173
|
+
chat.messages.push({ role: 'tool', tool_call_id: msg.call_id || '', content: msg.output || '' });
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 普通消息
|
|
179
|
+
let content: string | any[];
|
|
180
|
+
let embeddedToolCalls: ToolCall[] | undefined;
|
|
181
|
+
if (typeof msg.content === 'string') {
|
|
182
|
+
content = msg.content;
|
|
183
|
+
} else if (Array.isArray(msg.content)) {
|
|
184
|
+
const textParts: string[] = [];
|
|
185
|
+
const calls: ToolCall[] = [];
|
|
186
|
+
|
|
187
|
+
for (const b of msg.content) {
|
|
188
|
+
if (b.type === 'function_call') {
|
|
189
|
+
calls.push({ id: b.call_id || '', type: 'function', function: { name: b.name || '', arguments: b.arguments || '{}' } });
|
|
190
|
+
} else if (b.type === 'input_image') {
|
|
191
|
+
// DeepSeek V4 不支持图片输入,降级为文本提示
|
|
192
|
+
const mime = b.media_type || b.mime_type || 'image/png';
|
|
193
|
+
textParts.push(`[图片已接收 (${mime}),当前模型不支持直接查看图片内容]`);
|
|
194
|
+
} else if (b.type === 'input_file') {
|
|
195
|
+
// DeepSeek V4 不支持文件输入,提取可用文本或降级提示
|
|
196
|
+
if (b.text || b.content) {
|
|
197
|
+
textParts.push(b.text || b.content || '');
|
|
198
|
+
} else {
|
|
199
|
+
const name = b.filename || b.file_name || '未知文件';
|
|
200
|
+
textParts.push(`[文件已接收: ${name},当前模型不支持直接读取文件内容]`);
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const t = b.text || b.input_text || b.output_text || '';
|
|
204
|
+
if (t) textParts.push(t);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
content = textParts.join('');
|
|
209
|
+
if (calls.length > 0) embeddedToolCalls = calls;
|
|
210
|
+
} else {
|
|
211
|
+
content = '';
|
|
212
|
+
}
|
|
213
|
+
const role: string = msg.role === 'developer' ? 'system' : (msg.role || 'user');
|
|
214
|
+
|
|
215
|
+
const last = chat.messages[chat.messages.length - 1];
|
|
216
|
+
if (last && last.role === role && role === 'user') {
|
|
217
|
+
last.content = (last.content || '') + '\n' + content;
|
|
218
|
+
} else {
|
|
219
|
+
const chatMsg: ChatMessage = { role, content };
|
|
220
|
+
if (role === 'assistant') {
|
|
221
|
+
if (pendingReasoning) { chatMsg.reasoning_content = pendingReasoning; pendingReasoning = ''; }
|
|
222
|
+
if (embeddedToolCalls) chatMsg.tool_calls = embeddedToolCalls;
|
|
223
|
+
// If previous message is assistant(tool_calls) with no content (from function_call),
|
|
224
|
+
// merge its tool_calls into this text-bearing assistant to keep tool_call/tool pairing
|
|
225
|
+
if (last?.role === 'assistant' && last?.tool_calls?.length) {
|
|
226
|
+
chatMsg.tool_calls = [...(last.tool_calls), ...(chatMsg.tool_calls || [])];
|
|
227
|
+
// Preserve reasoning_content from the merged function_call assistant
|
|
228
|
+
if (last.reasoning_content && !chatMsg.reasoning_content) chatMsg.reasoning_content = last.reasoning_content;
|
|
229
|
+
chat.messages.pop(); // remove empty-shell assistant(tool_calls)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
chat.messages.push(chatMsg);
|
|
233
|
+
}
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Clean up orphaned tool messages (from truncation)
|
|
238
|
+
chat.messages = cleanOrphanTools(chat.messages);
|
|
239
|
+
// Validate tool_call/tool pairing before returning
|
|
240
|
+
chat.messages = validateToolPairing(chat.messages);
|
|
241
|
+
// DEBUG: log converted messages
|
|
242
|
+
console.log(`[Codex] converted ${chat.messages.length} messages:`);
|
|
243
|
+
chat.messages.forEach((m: ChatMessage, idx: number) => {
|
|
244
|
+
const tcs = m.tool_calls?.map(tc => tc.id.slice(0,16)).join(',') || '';
|
|
245
|
+
const tci = m.tool_call_id?.slice(0,16) || '';
|
|
246
|
+
console.log(`[Codex] [${idx}] ${m.role}${tcs ? ' tool_calls=['+tcs+']' : ''}${tci ? ' tool_call_id='+tci : ''}`);
|
|
247
|
+
});
|
|
248
|
+
return chat;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ================================================================
|
|
252
|
+
// 1.5 工具消息配对验证
|
|
253
|
+
// 确保每个 assistant(tool_calls) 后面紧跟对应数量的 tool 消息,
|
|
254
|
+
// 且 tool_call_id 一一对应。deepseek-v4-pro 等严格 API 需要此验证。
|
|
255
|
+
// ================================================================
|
|
256
|
+
function cleanOrphanTools(messages: ChatMessage[]): ChatMessage[] {
|
|
257
|
+
// Build set of all tool_call ids from assistant messages with tool_calls
|
|
258
|
+
const allToolCallIds = new Set<string>();
|
|
259
|
+
for (const m of messages) {
|
|
260
|
+
if (m.role === 'assistant' && m.tool_calls?.length) {
|
|
261
|
+
for (const tc of m.tool_calls) {
|
|
262
|
+
allToolCallIds.add(tc.id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Remove tool messages whose tool_call_id doesn't match any existing tool_call
|
|
267
|
+
const filtered = messages.filter(m => {
|
|
268
|
+
if (m.role !== 'tool') return true;
|
|
269
|
+
if (allToolCallIds.has(m.tool_call_id || '')) return true;
|
|
270
|
+
console.warn(`[Codex] 🗑️ 丢弃孤儿 tool 消息: call_id=${(m.tool_call_id || '').slice(0,16)}`);
|
|
271
|
+
return false;
|
|
272
|
+
});
|
|
273
|
+
return filtered.length === messages.length ? messages : filtered;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateToolPairing(messages: ChatMessage[]): ChatMessage[] {
|
|
277
|
+
const result: ChatMessage[] = [];
|
|
278
|
+
let i = 0;
|
|
279
|
+
|
|
280
|
+
while (i < messages.length) {
|
|
281
|
+
const msg = messages[i];
|
|
282
|
+
|
|
283
|
+
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
|
284
|
+
// Collect this assistant's tool_call ids
|
|
285
|
+
const expectedIds = new Set(msg.tool_calls.map(tc => tc.id));
|
|
286
|
+
const collectedTools: ChatMessage[] = [];
|
|
287
|
+
const seenIds = new Set<string>();
|
|
288
|
+
let j = i + 1;
|
|
289
|
+
|
|
290
|
+
// Collect following tool messages
|
|
291
|
+
while (j < messages.length && messages[j].role === 'tool') {
|
|
292
|
+
collectedTools.push(messages[j]);
|
|
293
|
+
seenIds.add(messages[j].tool_call_id || '');
|
|
294
|
+
j++;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Check for exact match
|
|
298
|
+
const unmatchedCalls = msg.tool_calls.filter(tc => !seenIds.has(tc.id));
|
|
299
|
+
const unmatchedTools = collectedTools.filter(t => !expectedIds.has(t.tool_call_id || ''));
|
|
300
|
+
|
|
301
|
+
if (unmatchedCalls.length > 0 || unmatchedTools.length > 0) {
|
|
302
|
+
console.warn(`[Codex] ⚠️ Tool pairing mismatch:`);
|
|
303
|
+
console.warn(`[Codex] expected: [${[...expectedIds].map(id=>id.slice(0,16)).join(', ')}]`);
|
|
304
|
+
console.warn(`[Codex] found: [${[...seenIds].map(id=>id.slice(0,16)).join(', ')}]`);
|
|
305
|
+
if (unmatchedCalls.length > 0) console.warn(`[Codex] unmatched calls: [${unmatchedCalls.map(tc=>tc.id.slice(0,16)).join(', ')}]`);
|
|
306
|
+
if (unmatchedTools.length > 0) console.warn(`[Codex] unmatched tools: [${unmatchedTools.map(t=>t.tool_call_id?.slice(0,16)).join(', ')}]`);
|
|
307
|
+
const matchingTools = collectedTools.filter(t => expectedIds.has(t.tool_call_id || ''));
|
|
308
|
+
if (matchingTools.length > 0) {
|
|
309
|
+
// Strip unmatched tool_calls from assistant, keep only those with matching tools
|
|
310
|
+
msg.tool_calls = msg.tool_calls!.filter(tc => seenIds.has(tc.id));
|
|
311
|
+
if (msg.tool_calls.length > 0) {
|
|
312
|
+
result.push(msg);
|
|
313
|
+
result.push(...matchingTools);
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
// P1 修复:不再丢弃整个 assistant 消息,而是保留原始内容并附加警告
|
|
317
|
+
// 这样下游上下文不会完全丢失
|
|
318
|
+
const warning = '[⚠️ IMtoAgent 警告:tool_call 未找到匹配的 tool 响应,保留原始消息防止上下文丢失]';
|
|
319
|
+
console.warn(`[Codex] ⚠️ 保留原始 assistant 消息(附带警告),而非丢弃`);
|
|
320
|
+
const preserved: ChatMessage = {
|
|
321
|
+
role: 'assistant',
|
|
322
|
+
content: warning + '\n' + (msg.content || ''),
|
|
323
|
+
tool_calls: undefined, // 移除无效的 tool_calls
|
|
324
|
+
};
|
|
325
|
+
if (msg.reasoning_content) preserved.reasoning_content = msg.reasoning_content;
|
|
326
|
+
result.push(preserved);
|
|
327
|
+
}
|
|
328
|
+
i = j;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Perfect match — keep as-is
|
|
333
|
+
result.push(msg);
|
|
334
|
+
result.push(...collectedTools);
|
|
335
|
+
i = j;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Not an assistant with tool_calls — keep as-is
|
|
340
|
+
result.push(msg);
|
|
341
|
+
i++;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ================================================================
|
|
348
|
+
// 2. 响应翻译: Chat SSE → Responses SSE
|
|
349
|
+
// ================================================================
|
|
350
|
+
async function streamResponse(upstreamRes: Response, resWriter: WritableStreamDefaultWriter<Uint8Array>): Promise<void> {
|
|
351
|
+
const enc = new TextEncoder();
|
|
352
|
+
let accumulatedText = '';
|
|
353
|
+
let accumulatedReasoning = '';
|
|
354
|
+
let outputIndex = 0;
|
|
355
|
+
const items: ResponseItem[] = [];
|
|
356
|
+
let msgId = '';
|
|
357
|
+
let msgIdx = -1;
|
|
358
|
+
let rsnIdx = -1;
|
|
359
|
+
let rsnActive = false, msgActive = false;
|
|
360
|
+
let hasStarted = false;
|
|
361
|
+
let finalUsage: any = {};
|
|
362
|
+
const pendingToolCalls = new Map<number, PendingToolCall>();
|
|
363
|
+
|
|
364
|
+
let streamBroken = false;
|
|
365
|
+
function emit(event: string, data: any): void {
|
|
366
|
+
if (streamBroken) return;
|
|
367
|
+
try {
|
|
368
|
+
resWriter.write(enc.encode(`event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`));
|
|
369
|
+
} catch {
|
|
370
|
+
streamBroken = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function ensureStarted(): void {
|
|
375
|
+
if (hasStarted) return;
|
|
376
|
+
hasStarted = true;
|
|
377
|
+
emit('response.created', { response: { id: 'resp_' + Date.now(), object: 'response', model: REPORTED_MODEL(), status: 'in_progress', output: [] } });
|
|
378
|
+
emit('response.in_progress', { response: { id: 'resp_' + Date.now(), object: 'response', model: REPORTED_MODEL(), status: 'in_progress' } });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const reader = upstreamRes.body!.getReader();
|
|
382
|
+
try {
|
|
383
|
+
const dec = new TextDecoder();
|
|
384
|
+
let buf = '';
|
|
385
|
+
|
|
386
|
+
while (true) {
|
|
387
|
+
let done: boolean, value: Uint8Array;
|
|
388
|
+
try {
|
|
389
|
+
({ done, value } = await reader.read());
|
|
390
|
+
} catch {
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
if (done || streamBroken) break;
|
|
394
|
+
buf += dec.decode(value, { stream: true });
|
|
395
|
+
const lines = buf.split('\n');
|
|
396
|
+
buf = lines.pop() || '';
|
|
397
|
+
|
|
398
|
+
for (const line of lines) {
|
|
399
|
+
if (streamBroken) break;
|
|
400
|
+
if (!line.startsWith('data: ')) continue;
|
|
401
|
+
const data = line.slice(6);
|
|
402
|
+
if (data === '[DONE]') continue;
|
|
403
|
+
|
|
404
|
+
let chunk: any;
|
|
405
|
+
try { chunk = JSON.parse(data); } catch { continue; }
|
|
406
|
+
|
|
407
|
+
const delta = chunk.choices?.[0]?.delta || {};
|
|
408
|
+
const finish = chunk.choices?.[0]?.finish_reason;
|
|
409
|
+
|
|
410
|
+
if (chunk.usage) finalUsage = chunk.usage;
|
|
411
|
+
|
|
412
|
+
if (delta.reasoning_content) {
|
|
413
|
+
ensureStarted();
|
|
414
|
+
if (!rsnActive) {
|
|
415
|
+
rsnIdx = outputIndex++;
|
|
416
|
+
emit('response.output_item.added', { output_index: rsnIdx, item: { id: 'rsn_0', type: 'reasoning', summary: [], status: 'in_progress' } });
|
|
417
|
+
rsnActive = true;
|
|
418
|
+
items.push({ id: 'rsn_0', type: 'reasoning', summary: [], status: 'completed' });
|
|
419
|
+
}
|
|
420
|
+
accumulatedReasoning += delta.reasoning_content;
|
|
421
|
+
emit('response.reasoning_text.delta', { item_id: 'rsn_0', output_index: rsnIdx, delta: delta.reasoning_content });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (delta.tool_calls) {
|
|
425
|
+
ensureStarted();
|
|
426
|
+
for (const tc of delta.tool_calls) {
|
|
427
|
+
const idx: number = tc.index ?? 0;
|
|
428
|
+
let pending = pendingToolCalls.get(idx);
|
|
429
|
+
if (!pending) {
|
|
430
|
+
pending = {
|
|
431
|
+
id: tc.id || ('call_' + Date.now() + '_' + idx),
|
|
432
|
+
name: '',
|
|
433
|
+
arguments: '',
|
|
434
|
+
outputIndex: outputIndex++,
|
|
435
|
+
itemId: 'fcal_' + Date.now() + '_' + idx,
|
|
436
|
+
started: false,
|
|
437
|
+
};
|
|
438
|
+
pendingToolCalls.set(idx, pending);
|
|
439
|
+
}
|
|
440
|
+
if (tc.id) pending.id = tc.id;
|
|
441
|
+
if (tc.function?.name) {
|
|
442
|
+
// Reverse translation: map DeepSeek response tool names back to upstream names
|
|
443
|
+
pending.name = tc.function.name;
|
|
444
|
+
}
|
|
445
|
+
if (tc.function?.arguments) {
|
|
446
|
+
pending.arguments += tc.function.arguments;
|
|
447
|
+
if (!pending.started) {
|
|
448
|
+
pending.started = true;
|
|
449
|
+
emit('response.output_item.added', {
|
|
450
|
+
output_index: pending.outputIndex,
|
|
451
|
+
item: { id: pending.itemId, type: 'function_call', call_id: pending.id, name: pending.name, arguments: '', status: 'in_progress' }
|
|
452
|
+
});
|
|
453
|
+
items.push({ id: pending.itemId, type: 'function_call', call_id: pending.id, name: pending.name, arguments: '', status: 'completed' });
|
|
454
|
+
}
|
|
455
|
+
emit('response.function_call_arguments.delta', {
|
|
456
|
+
item_id: pending.itemId, output_index: pending.outputIndex, delta: tc.function.arguments
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (delta.content) {
|
|
463
|
+
ensureStarted();
|
|
464
|
+
if (!msgActive) {
|
|
465
|
+
if (rsnActive) {
|
|
466
|
+
emit('response.output_item.done', { output_index: rsnIdx, item: { id: 'rsn_0', type: 'reasoning', summary: [{ type: 'summary_text', text: accumulatedReasoning }], status: 'completed' } });
|
|
467
|
+
rsnActive = false;
|
|
468
|
+
}
|
|
469
|
+
msgIdx = outputIndex++;
|
|
470
|
+
msgId = 'msg_' + Date.now();
|
|
471
|
+
emit('response.output_item.added', { output_index: msgIdx, item: { id: msgId, type: 'message', role: 'assistant', content: [], status: 'in_progress' } });
|
|
472
|
+
emit('response.content_part.added', { item_id: msgId, output_index: msgIdx, content_index: 0, part: { type: 'output_text', text: '' } });
|
|
473
|
+
msgActive = true;
|
|
474
|
+
items.push({ id: msgId, type: 'message', role: 'assistant', content: [], status: 'completed' });
|
|
475
|
+
}
|
|
476
|
+
accumulatedText += delta.content;
|
|
477
|
+
emit('response.output_text.delta', { item_id: msgId, output_index: msgIdx, content_index: 0, delta: delta.content });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (finish) {
|
|
481
|
+
if (rsnActive) {
|
|
482
|
+
if (accumulatedReasoning) {
|
|
483
|
+
const rsnItem: ResponseItem = { id: 'rsn_0', type: 'reasoning', summary: [{ type: 'summary_text', text: accumulatedReasoning }], status: 'completed' };
|
|
484
|
+
items[rsnIdx] = rsnItem;
|
|
485
|
+
emit('response.output_item.done', { output_index: rsnIdx, item: rsnItem });
|
|
486
|
+
}
|
|
487
|
+
rsnActive = false;
|
|
488
|
+
}
|
|
489
|
+
for (const [, pending] of pendingToolCalls) {
|
|
490
|
+
if (pending.started) {
|
|
491
|
+
const fcItem: ResponseItem = { id: pending.itemId, type: 'function_call', call_id: pending.id, name: pending.name, arguments: pending.arguments, status: 'completed' };
|
|
492
|
+
items[pending.outputIndex] = fcItem;
|
|
493
|
+
emit('response.output_item.done', { output_index: pending.outputIndex, item: fcItem });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
pendingToolCalls.clear();
|
|
497
|
+
if (msgActive) {
|
|
498
|
+
const msgItem: ResponseItem = { id: msgId, type: 'message', role: 'assistant', content: [{ type: 'output_text', text: accumulatedText }], status: 'completed' };
|
|
499
|
+
items[msgIdx] = msgItem;
|
|
500
|
+
emit('response.output_text.done', { item_id: msgId, output_index: msgIdx, content_index: 0, text: accumulatedText });
|
|
501
|
+
emit('response.content_part.done', { item_id: msgId, output_index: msgIdx, content_index: 0, part: { type: 'output_text', text: accumulatedText } });
|
|
502
|
+
emit('response.output_item.done', { output_index: msgIdx, item: msgItem });
|
|
503
|
+
msgActive = false;
|
|
504
|
+
}
|
|
505
|
+
emit('response.completed', {
|
|
506
|
+
response: {
|
|
507
|
+
id: 'resp_' + Date.now(), object: 'response', model: REPORTED_MODEL(), status: 'completed',
|
|
508
|
+
output: items,
|
|
509
|
+
usage: {
|
|
510
|
+
input_tokens: finalUsage.prompt_tokens || 0,
|
|
511
|
+
output_tokens: finalUsage.completion_tokens || 0,
|
|
512
|
+
total_tokens: finalUsage.total_tokens || 0,
|
|
513
|
+
},
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
accumulateProxyUsage(finalUsage.prompt_tokens || 0, finalUsage.completion_tokens || 0);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
// 静默处理
|
|
522
|
+
} finally {
|
|
523
|
+
try { reader.cancel(); } catch {}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ================================================================
|
|
528
|
+
// ================================================================
|
|
529
|
+
// usage 累加器 — 供网关读取 Codex 的 Token/成本统计
|
|
530
|
+
// ================================================================
|
|
531
|
+
let _proxyUsage = { inputTokens: 0, outputTokens: 0 };
|
|
532
|
+
|
|
533
|
+
export function getProxyUsage() {
|
|
534
|
+
return { ..._proxyUsage };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function resetProxyUsage() {
|
|
538
|
+
_proxyUsage = { inputTokens: 0, outputTokens: 0 };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function accumulateProxyUsage(inputTokens: number, outputTokens: number) {
|
|
542
|
+
_proxyUsage.inputTokens += inputTokens;
|
|
543
|
+
_proxyUsage.outputTokens += outputTokens;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 3. 请求处理器(供主代理端口 18899 按路径分发调用)
|
|
547
|
+
// ================================================================
|
|
548
|
+
|
|
549
|
+
import type * as http from 'http';
|
|
550
|
+
|
|
551
|
+
export async function handleCodexRequest(
|
|
552
|
+
reqBody: string,
|
|
553
|
+
reqPath: string,
|
|
554
|
+
reqMethod: string,
|
|
555
|
+
res: http.ServerResponse
|
|
556
|
+
): Promise<void> {
|
|
557
|
+
try {
|
|
558
|
+
// GET /health
|
|
559
|
+
if (reqMethod === 'GET' && reqPath === '/health') {
|
|
560
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', model: REPORTED_MODEL() })); return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// GET /v1/models
|
|
564
|
+
if (reqMethod === 'GET' && reqPath.includes('/models')) {
|
|
565
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ object: 'list', data: [{ id: REPORTED_MODEL(), object: 'model', created: Date.now(), owned_by: 'openai' }] })); return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// POST /v1/responses → Chat Completions
|
|
569
|
+
if (reqMethod === 'POST' && (reqPath === '/v1/responses' || reqPath.includes('/responses'))) {
|
|
570
|
+
const body = JSON.parse(reqBody);
|
|
571
|
+
const chatReq = responsesToChat(body);
|
|
572
|
+
fs.writeFileSync('/tmp/codex-body.json', JSON.stringify(body, null, 2));
|
|
573
|
+
|
|
574
|
+
// 🧠 动态注入灵魂 + IM 能力到系统 Prompt
|
|
575
|
+
const ctx = getCurrentBot();
|
|
576
|
+
const botName = ctx?.botName || 'CodexBot';
|
|
577
|
+
|
|
578
|
+
const systemPrompt = buildSystemPrompt({
|
|
579
|
+
caps: ctx?.caps || null,
|
|
580
|
+
botName,
|
|
581
|
+
});
|
|
582
|
+
console.log(`[Codex] 📝 system prompt built (${systemPrompt.length} chars, bot=${botName})`);
|
|
583
|
+
|
|
584
|
+
let sysMsg = chatReq.messages.find((m: ChatMessage) => m.role === 'system');
|
|
585
|
+
if (!sysMsg) {
|
|
586
|
+
sysMsg = { role: 'system', content: '' };
|
|
587
|
+
chatReq.messages.unshift(sysMsg);
|
|
588
|
+
}
|
|
589
|
+
if (typeof sysMsg.content !== 'string') sysMsg.content = '';
|
|
590
|
+
sysMsg.content = sysMsg.content + '\n\n---\n\n' + systemPrompt;
|
|
591
|
+
|
|
592
|
+
const roles = chatReq.messages?.map((m: ChatMessage) => m.role).join(',');
|
|
593
|
+
console.log(`[Codex] → ${chatReq.model} [${roles}] tools:${chatReq.tools?.length || 0}`);
|
|
594
|
+
|
|
595
|
+
const ac = new AbortController();
|
|
596
|
+
const timeout = setTimeout(() => ac.abort(), 180_000);
|
|
597
|
+
|
|
598
|
+
let upstreamRes: Response;
|
|
599
|
+
try {
|
|
600
|
+
upstreamRes = await fetch(UPSTREAM(), {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY()}` },
|
|
603
|
+
body: JSON.stringify(chatReq),
|
|
604
|
+
signal: ac.signal,
|
|
605
|
+
});
|
|
606
|
+
} catch (e: any) {
|
|
607
|
+
console.error(`[Codex] ❌ fetch failed: ${e.message}`);
|
|
608
|
+
res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'upstream unavailable' })); return;
|
|
609
|
+
} finally {
|
|
610
|
+
clearTimeout(timeout);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!upstreamRes.ok) {
|
|
614
|
+
const errText = await upstreamRes.text();
|
|
615
|
+
console.error(`[Codex] ❌ ${upstreamRes.status}: ${errText.slice(0, 200)}`);
|
|
616
|
+
res.writeHead(upstreamRes.status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: errText.slice(0, 500) })); return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 流式:streamResponse 转换格式后写入 Node response
|
|
620
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
621
|
+
|
|
622
|
+
// 创建 WritableStream 桥接到 Node res
|
|
623
|
+
const writable = new WritableStream({
|
|
624
|
+
write(chunk: Uint8Array) {
|
|
625
|
+
res.write(Buffer.from(chunk));
|
|
626
|
+
},
|
|
627
|
+
close() {
|
|
628
|
+
res.end();
|
|
629
|
+
},
|
|
630
|
+
abort(err: any) {
|
|
631
|
+
res.end();
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
const writer = writable.getWriter();
|
|
635
|
+
await streamResponse(upstreamRes, writer).catch((e: any) => {
|
|
636
|
+
console.error(`[Codex] streamResponse error: ${e?.message || e}`);
|
|
637
|
+
}).finally(() => {
|
|
638
|
+
try { writer.close(); } catch {}
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); return;
|
|
644
|
+
} catch (e: any) {
|
|
645
|
+
console.error(`[Codex] 💥 unhandled: ${e.message}`);
|
|
646
|
+
res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'internal error' })); return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 兼容旧引用(不再启动独立服务器)
|
|
651
|
+
export function startCodexProxy(_port?: number): Promise<number> {
|
|
652
|
+
console.log('[Codex Proxy] 已合并到 18899 端口');
|
|
653
|
+
return Promise.resolve(18899);
|
|
654
|
+
}
|
|
655
|
+
export function stopCodexProxy(): Promise<void> {
|
|
656
|
+
return Promise.resolve();
|
|
657
|
+
}
|