protocol-proxy 1.0.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 ADDED
@@ -0,0 +1,94 @@
1
+ # Protocol Proxy
2
+
3
+ OpenAI / Anthropic 协议转换透明代理服务。
4
+
5
+ ## 功能特性
6
+
7
+ - **双向协议转换**:OpenAI ↔ Anthropic 自动识别并转换
8
+ - **多端口代理**:每个代理端口独立运行,互不干扰
9
+ - **默认 Model 注入**:可为每个代理配置默认 Model,请求未携带 model 时自动注入
10
+ - **流式输出支持**:SSE 实时转换,包括工具调用场景
11
+ - **工具调用转换**:functions/tool_calls ↔ tool_use/tool_result 完整映射
12
+ - **管理前端**:内置 Web UI,可视化配置代理和目标
13
+ - **Agent 身份验证**:可选 Bearer Token 认证
14
+ - **配置热更新**:修改目标地址、Model 等配置后即时生效,无需重启代理
15
+
16
+ ## 快速开始
17
+
18
+ ```bash
19
+ # 1. 安装依赖
20
+ npm install
21
+
22
+ # 2. 启动服务
23
+ npm start
24
+
25
+ # 3. 打开管理界面
26
+ open http://localhost:3000
27
+ ```
28
+
29
+ ## 配置说明
30
+
31
+ 每个代理端口对应一个目标供应商配置:
32
+
33
+ ```json
34
+ {
35
+ "proxies": [
36
+ {
37
+ "id": "default",
38
+ "name": "默认代理",
39
+ "port": 8080,
40
+ "requireAuth": false,
41
+ "authToken": null,
42
+ "target": {
43
+ "providerUrl": "https://api.openai.com",
44
+ "protocol": "openai",
45
+ "defaultModel": "gpt-4o",
46
+ "apiKey": "sk-xxx"
47
+ }
48
+ }
49
+ ]
50
+ }
51
+ ```
52
+
53
+ | 字段 | 说明 |
54
+ |------|------|
55
+ | `port` | 代理监听端口 |
56
+ | `requireAuth` | 是否启用 Agent 认证 |
57
+ | `authToken` | 认证 Token(启用时自动生成) |
58
+ | `target.providerUrl` | 目标供应商地址 |
59
+ | `target.protocol` | 目标协议:`openai` 或 `anthropic` |
60
+ | `target.defaultModel` | 默认 Model,请求未携带 model 时注入 |
61
+ | `target.apiKey` | 供应商 API Key |
62
+
63
+ ## 使用流程
64
+
65
+ 1. 在管理界面(`http://localhost:3000`)创建代理,配置端口和目标供应商
66
+ 2. Agent 配置 base URL 为代理地址,例如 `http://localhost:8080`
67
+ 3. Agent 正常发送请求(OpenAI 或 Anthropic 格式均可)
68
+ 4. 代理自动识别入站协议,必要时进行协议转换后转发到目标供应商
69
+
70
+ ## 技术栈
71
+
72
+ - Node.js 20+(原生 fetch + ReadableStream)
73
+ - Express(HTTP 服务)
74
+ - 纯 HTML/JS 管理前端
75
+
76
+ ## 文件结构
77
+
78
+ ```
79
+ protocol-proxy/
80
+ ├── server.js # 管理服务器入口
81
+ ├── lib/
82
+ │ ├── config-store.js # 配置持久化
83
+ │ ├── proxy-manager.js # 代理端口生命周期管理
84
+ │ ├── proxy-server.js # 单个代理端口的请求处理
85
+ │ ├── detector.js # 入站协议检测
86
+ │ └── converters/
87
+ │ ├── openai-to-anthropic.js
88
+ │ ├── anthropic-to-openai.js
89
+ │ └── sse-helpers.js
90
+ ├── config/
91
+ │ └── proxies.json # 配置文件
92
+ ├── public/ # 前端静态文件
93
+ └── package.json
94
+ ```
@@ -0,0 +1,3 @@
1
+ {
2
+ "proxies": []
3
+ }
@@ -0,0 +1,119 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const BASE_DIR = process.pkg
5
+ ? path.dirname(process.execPath)
6
+ : path.join(__dirname, '..');
7
+ const CONFIG_PATH = path.join(BASE_DIR, 'config', 'proxies.json');
8
+
9
+ let configCache = null;
10
+
11
+ function normalizeModels(target) {
12
+ if (!target) return target;
13
+ const models = Array.isArray(target.models) ? target.models : [];
14
+ const normalized = models
15
+ .filter(model => typeof model === 'string')
16
+ .map(model => model.trim())
17
+ .filter(Boolean);
18
+
19
+ if (target.defaultModel && !normalized.includes(target.defaultModel)) {
20
+ normalized.unshift(target.defaultModel);
21
+ }
22
+
23
+ return {
24
+ ...target,
25
+ models: Array.from(new Set(normalized)),
26
+ };
27
+ }
28
+
29
+ function normalizeProxy(proxy) {
30
+ if (!proxy) return proxy;
31
+ return {
32
+ ...proxy,
33
+ target: normalizeModels(proxy.target),
34
+ };
35
+ }
36
+
37
+ function normalizeConfig(config) {
38
+ const proxies = Array.isArray(config?.proxies) ? config.proxies : [];
39
+ return {
40
+ ...config,
41
+ proxies: proxies.map(normalizeProxy),
42
+ };
43
+ }
44
+
45
+ function loadConfig() {
46
+ try {
47
+ if (!fs.existsSync(CONFIG_PATH)) {
48
+ configCache = { proxies: [] };
49
+ return configCache;
50
+ }
51
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
52
+ configCache = normalizeConfig(JSON.parse(raw));
53
+ return configCache;
54
+ } catch (err) {
55
+ console.error('加载配置失败:', err.message);
56
+ return configCache || { proxies: [] };
57
+ }
58
+ }
59
+
60
+ function saveConfig(config) {
61
+ try {
62
+ const normalizedConfig = normalizeConfig(config);
63
+ const dir = path.dirname(CONFIG_PATH);
64
+ if (!fs.existsSync(dir)) {
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ }
67
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalizedConfig, null, 2), 'utf-8');
68
+ configCache = normalizedConfig;
69
+ return true;
70
+ } catch (err) {
71
+ console.error('保存配置失败:', err.message);
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function getProxies() {
77
+ return loadConfig().proxies || [];
78
+ }
79
+
80
+ function getProxyById(id) {
81
+ return getProxies().find(p => p.id === id);
82
+ }
83
+
84
+ function addProxy(proxy) {
85
+ const config = loadConfig();
86
+ config.proxies = config.proxies || [];
87
+ proxy.id = proxy.id || 'proxy-' + Date.now();
88
+ config.proxies.push(proxy);
89
+ saveConfig(config);
90
+ return proxy;
91
+ }
92
+
93
+ function updateProxy(id, updates) {
94
+ const config = loadConfig();
95
+ const idx = (config.proxies || []).findIndex(p => p.id === id);
96
+ if (idx === -1) return null;
97
+ config.proxies[idx] = { ...config.proxies[idx], ...updates, id };
98
+ saveConfig(config);
99
+ return config.proxies[idx];
100
+ }
101
+
102
+ function removeProxy(id) {
103
+ const config = loadConfig();
104
+ const idx = (config.proxies || []).findIndex(p => p.id === id);
105
+ if (idx === -1) return null;
106
+ const removed = config.proxies.splice(idx, 1)[0];
107
+ saveConfig(config);
108
+ return removed;
109
+ }
110
+
111
+ module.exports = {
112
+ loadConfig,
113
+ saveConfig,
114
+ getProxies,
115
+ getProxyById,
116
+ addProxy,
117
+ updateProxy,
118
+ removeProxy,
119
+ };
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Anthropic → OpenAI 协议转换
3
+ */
4
+
5
+ const { encodeOpenAIEvent, encodeOpenAIDone, encodeAnthropicEvent } = require('./sse-helpers');
6
+
7
+ // ==================== 请求转换 ====================
8
+
9
+ function generateCallId() {
10
+ return 'call_' + Math.random().toString(36).slice(2, 11) + Math.random().toString(36).slice(2, 11);
11
+ }
12
+
13
+ function convertRequest(body, targetModel) {
14
+ const result = {
15
+ model: targetModel,
16
+ max_tokens: body.max_tokens || 4096,
17
+ stream: body.stream || false,
18
+ };
19
+
20
+ if (body.temperature !== undefined) result.temperature = body.temperature;
21
+ if (body.top_p !== undefined) result.top_p = body.top_p;
22
+ if (body.stop_sequences !== undefined) result.stop = body.stop_sequences;
23
+
24
+ // 构建 tool_use_id 映射表(Anthropic id -> OpenAI id)
25
+ const idMap = new Map();
26
+ for (const msg of (body.messages || [])) {
27
+ if (msg.role === 'assistant' && Array.isArray(msg.content)) {
28
+ for (const block of msg.content) {
29
+ if (block.type === 'tool_use' && block.id && !idMap.has(block.id)) {
30
+ idMap.set(block.id, generateCallId());
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ // 构建 messages 数组
37
+ const messages = [];
38
+
39
+ // system 转为 messages 中的 system 角色
40
+ if (body.system) {
41
+ const systemContent = typeof body.system === 'string'
42
+ ? body.system
43
+ : body.system.map(s => s.text || s).join('\n');
44
+ messages.push({ role: 'system', content: systemContent });
45
+ }
46
+
47
+ // 转换其他消息
48
+ for (const msg of (body.messages || [])) {
49
+ const converted = convertMessage(msg, idMap);
50
+ if (Array.isArray(converted)) {
51
+ messages.push(...converted);
52
+ } else {
53
+ messages.push(converted);
54
+ }
55
+ }
56
+
57
+ result.messages = messages;
58
+
59
+ // 转换 tools
60
+ if (body.tools && Array.isArray(body.tools)) {
61
+ result.tools = body.tools.map(t => ({
62
+ type: 'function',
63
+ function: {
64
+ name: t.name,
65
+ description: t.description,
66
+ parameters: t.input_schema || { type: 'object', properties: {} },
67
+ },
68
+ }));
69
+ }
70
+
71
+ // 转换 tool_choice
72
+ if (body.tool_choice) {
73
+ result.tool_choice = convertToolChoice(body.tool_choice);
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ function convertMessage(msg, idMap) {
80
+ if (!msg || !msg.role) return msg;
81
+
82
+ // 处理 content 数组
83
+ if (Array.isArray(msg.content)) {
84
+ const textParts = [];
85
+ const toolResults = [];
86
+ const toolUses = [];
87
+
88
+ for (const block of msg.content) {
89
+ if (block.type === 'text') {
90
+ textParts.push(block.text);
91
+ } else if (block.type === 'tool_result') {
92
+ // 使用映射后的 OpenAI 格式 id
93
+ const openaiId = idMap?.get(block.tool_use_id) || block.tool_use_id;
94
+ toolResults.push({
95
+ role: 'tool',
96
+ tool_call_id: openaiId,
97
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
98
+ });
99
+ } else if (block.type === 'tool_use') {
100
+ // 使用映射后的 OpenAI 格式 id
101
+ const openaiId = idMap?.get(block.id) || block.id;
102
+ toolUses.push({
103
+ id: openaiId,
104
+ type: 'function',
105
+ function: {
106
+ name: block.name,
107
+ arguments: JSON.stringify(block.input || {}),
108
+ },
109
+ });
110
+ }
111
+ }
112
+
113
+ // assistant 消息含 tool_use → 需要拆分为 assistant + tool
114
+ if (msg.role === 'assistant' && toolUses.length > 0) {
115
+ const result = [];
116
+ result.push({
117
+ role: 'assistant',
118
+ content: textParts.join('') || '',
119
+ tool_calls: toolUses,
120
+ });
121
+ return result;
122
+ }
123
+
124
+ // user 消息含 tool_result → 拆分为多个 tool 消息
125
+ if (msg.role === 'user' && toolResults.length > 0) {
126
+ const result = [];
127
+ // OpenAI 要求 tool 消息紧跟 assistant,user text 如果有的话应该放在 tool 之前
128
+ // 但通常 tool_result 消息不会有额外的 user text
129
+ if (textParts.length > 0) {
130
+ result.push({ role: 'user', content: textParts.join('') });
131
+ }
132
+ result.push(...toolResults);
133
+ return result;
134
+ }
135
+
136
+ // 普通情况
137
+ return { role: msg.role, content: textParts.join('') };
138
+ }
139
+
140
+ return { role: msg.role, content: msg.content };
141
+ }
142
+
143
+ function convertToolChoice(tc) {
144
+ if (typeof tc === 'string') {
145
+ if (tc === 'auto') return 'auto';
146
+ if (tc === 'none') return 'none';
147
+ if (tc === 'any') return 'required';
148
+ return 'auto';
149
+ }
150
+ if (tc.type === 'auto') return 'auto';
151
+ if (tc.type === 'none') return 'none';
152
+ if (tc.type === 'any') return 'required';
153
+ if (tc.type === 'tool') {
154
+ return { type: 'function', function: { name: tc.name } };
155
+ }
156
+ return 'auto';
157
+ }
158
+
159
+ // ==================== 响应转换 ====================
160
+
161
+ function convertResponse(openaiBody) {
162
+ const choice = openaiBody.choices?.[0];
163
+ if (!choice) {
164
+ return { id: openaiBody.id, type: 'message', role: 'assistant', content: [] };
165
+ }
166
+
167
+ const content = [];
168
+ const message = choice.message || {};
169
+
170
+ // 文本内容
171
+ if (message.content) {
172
+ content.push({ type: 'text', text: message.content });
173
+ }
174
+
175
+ // tool_calls → tool_use
176
+ if (message.tool_calls) {
177
+ for (const tc of message.tool_calls) {
178
+ content.push({
179
+ type: 'tool_use',
180
+ id: tc.id,
181
+ name: tc.function?.name,
182
+ input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
183
+ });
184
+ }
185
+ }
186
+
187
+ return {
188
+ id: openaiBody.id,
189
+ type: 'message',
190
+ role: 'assistant',
191
+ content,
192
+ stop_reason: mapFinishReason(choice.finish_reason),
193
+ usage: openaiBody.usage ? {
194
+ input_tokens: openaiBody.usage.prompt_tokens,
195
+ output_tokens: openaiBody.usage.completion_tokens,
196
+ } : undefined,
197
+ };
198
+ }
199
+
200
+ function mapFinishReason(reason) {
201
+ if (!reason) return null;
202
+ if (reason === 'stop') return 'end_turn';
203
+ if (reason === 'length') return 'max_tokens';
204
+ if (reason === 'tool_calls') return 'tool_use';
205
+ if (reason === 'content_filter') return 'end_turn';
206
+ return reason;
207
+ }
208
+
209
+ // ==================== SSE 流式转换 ====================
210
+
211
+ function createSSEConverter(targetModel) {
212
+ const state = {
213
+ messageId: null,
214
+ started: false,
215
+ textBlockStarted: false,
216
+ toolCalls: new Map(), // index -> { id, name, args }
217
+ buffer: '',
218
+ };
219
+
220
+ return {
221
+ convertChunk(chunkText) {
222
+ let output = '';
223
+ state.buffer += chunkText;
224
+ const lines = state.buffer.split('\n');
225
+ state.buffer = lines.pop() || '';
226
+
227
+ for (const line of lines) {
228
+ const converted = processLine(line.trim(), state, targetModel);
229
+ if (converted) output += converted;
230
+ }
231
+
232
+ return output;
233
+ },
234
+ flush() {
235
+ let output = '';
236
+ if (state.started) {
237
+ // 确保发送 message_stop
238
+ output += encodeAnthropicEvent('message_stop', { type: 'message_stop' });
239
+ }
240
+ return output;
241
+ },
242
+ };
243
+ }
244
+
245
+ function processLine(line, state, targetModel) {
246
+ if (!line.startsWith('data:')) return '';
247
+ const dataStr = line.slice(5).trim();
248
+ if (dataStr === '[DONE]') {
249
+ return encodeAnthropicEvent('message_stop', { type: 'message_stop' });
250
+ }
251
+
252
+ let chunk;
253
+ try {
254
+ chunk = JSON.parse(dataStr);
255
+ } catch {
256
+ return '';
257
+ }
258
+
259
+ if (!chunk || !chunk.choices) return '';
260
+
261
+ const choice = chunk.choices[0];
262
+ if (!choice) return '';
263
+
264
+ const delta = choice.delta || {};
265
+ let output = '';
266
+
267
+ // 第一个有 role 的 chunk → message_start
268
+ if (delta.role && !state.started) {
269
+ state.started = true;
270
+ state.messageId = chunk.id;
271
+ output += encodeAnthropicEvent('message_start', {
272
+ type: 'message_start',
273
+ message: {
274
+ id: chunk.id,
275
+ type: 'message',
276
+ role: 'assistant',
277
+ model: targetModel,
278
+ content: [],
279
+ stop_reason: null,
280
+ stop_sequence: null,
281
+ usage: { input_tokens: 0, output_tokens: 0 },
282
+ },
283
+ });
284
+ }
285
+
286
+ // 文本内容
287
+ if (delta.content !== undefined && delta.content !== null) {
288
+ if (!state.textBlockStarted) {
289
+ state.textBlockStarted = true;
290
+ output += encodeAnthropicEvent('content_block_start', {
291
+ type: 'content_block_start',
292
+ index: 0,
293
+ content_block: { type: 'text', text: '' },
294
+ });
295
+ }
296
+ output += encodeAnthropicEvent('content_block_delta', {
297
+ type: 'content_block_delta',
298
+ index: 0,
299
+ delta: { type: 'text_delta', text: delta.content },
300
+ });
301
+ }
302
+
303
+ // tool_calls
304
+ if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
305
+ for (const tc of delta.tool_calls) {
306
+ const idx = tc.index || 0;
307
+ let tool = state.toolCalls.get(idx);
308
+
309
+ if (!tool) {
310
+ // 新的 tool_call
311
+ tool = { id: tc.id, name: tc.function?.name, args: '' };
312
+ state.toolCalls.set(idx, tool);
313
+ output += encodeAnthropicEvent('content_block_start', {
314
+ type: 'content_block_start',
315
+ index: idx + 1, // text block 占 index 0
316
+ content_block: {
317
+ type: 'tool_use',
318
+ id: tc.id,
319
+ name: tc.function?.name,
320
+ input: {},
321
+ },
322
+ });
323
+ }
324
+
325
+ if (tc.function?.arguments) {
326
+ tool.args += tc.function.arguments;
327
+ output += encodeAnthropicEvent('content_block_delta', {
328
+ type: 'content_block_delta',
329
+ index: idx + 1,
330
+ delta: { type: 'input_json_delta', partial_json: tc.function.arguments },
331
+ });
332
+ }
333
+ }
334
+ }
335
+
336
+ // finish_reason
337
+ if (choice.finish_reason) {
338
+ const stopReason = mapFinishReason(choice.finish_reason);
339
+ if (state.textBlockStarted) {
340
+ output += encodeAnthropicEvent('content_block_stop', {
341
+ type: 'content_block_stop',
342
+ index: 0,
343
+ });
344
+ }
345
+ for (let i = 0; i < state.toolCalls.size; i++) {
346
+ output += encodeAnthropicEvent('content_block_stop', {
347
+ type: 'content_block_stop',
348
+ index: i + 1,
349
+ });
350
+ }
351
+ output += encodeAnthropicEvent('message_delta', {
352
+ type: 'message_delta',
353
+ delta: { stop_reason: stopReason, stop_sequence: null },
354
+ usage: { output_tokens: 0 },
355
+ });
356
+ output += encodeAnthropicEvent('message_stop', { type: 'message_stop' });
357
+ }
358
+
359
+ return output;
360
+ }
361
+
362
+ module.exports = {
363
+ convertRequest,
364
+ convertResponse,
365
+ createSSEConverter,
366
+ };