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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * OpenAI → Anthropic 协议转换
3
+ */
4
+
5
+ const { encodeAnthropicEvent } = require('./sse-helpers');
6
+
7
+ // ==================== 请求转换 ====================
8
+
9
+ function convertRequest(body, targetModel) {
10
+ const result = {
11
+ model: targetModel,
12
+ max_tokens: body.max_tokens || 4096,
13
+ stream: body.stream || false,
14
+ };
15
+
16
+ if (body.temperature !== undefined) result.temperature = body.temperature;
17
+ if (body.top_p !== undefined) result.top_p = body.top_p;
18
+ if (body.stop !== undefined) result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
19
+
20
+ // 处理 messages 和 system
21
+ const systemMessages = [];
22
+ const otherMessages = [];
23
+
24
+ for (const msg of (body.messages || [])) {
25
+ if (msg.role === 'system') {
26
+ systemMessages.push(typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content));
27
+ } else {
28
+ otherMessages.push(msg);
29
+ }
30
+ }
31
+
32
+ if (systemMessages.length > 0) {
33
+ result.system = systemMessages.join('\n\n');
34
+ }
35
+
36
+ // 转换消息角色和内容
37
+ result.messages = otherMessages.map(msg => convertMessage(msg));
38
+
39
+ // 转换 tools / functions
40
+ if (body.tools && Array.isArray(body.tools)) {
41
+ result.tools = body.tools.map(t => convertTool(t));
42
+ } else if (body.functions && Array.isArray(body.functions)) {
43
+ // 旧版 functions 转 tools
44
+ result.tools = body.functions.map(f => ({
45
+ name: f.name,
46
+ description: f.description,
47
+ input_schema: f.parameters || { type: 'object', properties: {} },
48
+ }));
49
+ }
50
+
51
+ // 转换 tool_choice
52
+ if (body.tool_choice) {
53
+ result.tool_choice = convertToolChoice(body.tool_choice);
54
+ }
55
+
56
+ return result;
57
+ }
58
+
59
+ function convertMessage(msg) {
60
+ if (msg.role === 'tool') {
61
+ // OpenAI tool result → Anthropic tool_result content block
62
+ return {
63
+ role: 'user',
64
+ content: [{
65
+ type: 'tool_result',
66
+ tool_use_id: msg.tool_call_id,
67
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
68
+ }],
69
+ };
70
+ }
71
+
72
+ if (msg.role === 'assistant' && msg.tool_calls) {
73
+ // assistant message with tool_calls → assistant with tool_use blocks
74
+ const content = [];
75
+ if (msg.content) {
76
+ content.push({ type: 'text', text: msg.content });
77
+ }
78
+ for (const tc of msg.tool_calls) {
79
+ content.push({
80
+ type: 'tool_use',
81
+ id: tc.id,
82
+ name: tc.function?.name,
83
+ input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
84
+ });
85
+ }
86
+ return { role: 'assistant', content };
87
+ }
88
+
89
+ // 普通消息
90
+ return {
91
+ role: msg.role,
92
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
93
+ };
94
+ }
95
+
96
+ function convertTool(tool) {
97
+ return {
98
+ name: tool.function?.name || tool.name,
99
+ description: tool.function?.description || tool.description,
100
+ input_schema: tool.function?.parameters || tool.input_schema || { type: 'object', properties: {} },
101
+ };
102
+ }
103
+
104
+ function convertToolChoice(tc) {
105
+ if (tc === 'auto') return { type: 'auto' };
106
+ if (tc === 'none') return { type: 'none' };
107
+ if (tc === 'required') return { type: 'any' };
108
+ if (typeof tc === 'object' && tc.type === 'function') {
109
+ return { type: 'tool', name: tc.function?.name };
110
+ }
111
+ return { type: 'auto' };
112
+ }
113
+
114
+ // ==================== 响应转换 ====================
115
+
116
+ function convertResponse(anthropicBody) {
117
+ const choice = {
118
+ index: 0,
119
+ message: {
120
+ role: 'assistant',
121
+ content: '',
122
+ },
123
+ finish_reason: null,
124
+ };
125
+
126
+ // 提取文本内容和 tool_calls
127
+ const toolCalls = [];
128
+ const textParts = [];
129
+
130
+ for (const block of (anthropicBody.content || [])) {
131
+ if (block.type === 'text') {
132
+ textParts.push(block.text);
133
+ } else if (block.type === 'tool_use') {
134
+ toolCalls.push({
135
+ id: block.id,
136
+ type: 'function',
137
+ function: {
138
+ name: block.name,
139
+ arguments: JSON.stringify(block.input || {}),
140
+ },
141
+ });
142
+ }
143
+ }
144
+
145
+ choice.message.content = textParts.join('');
146
+ if (toolCalls.length > 0) {
147
+ choice.message.tool_calls = toolCalls;
148
+ }
149
+
150
+ // 映射 stop_reason
151
+ choice.finish_reason = mapStopReason(anthropicBody.stop_reason);
152
+
153
+ return {
154
+ id: anthropicBody.id,
155
+ object: 'chat.completion',
156
+ created: Math.floor(Date.now() / 1000),
157
+ model: anthropicBody.model,
158
+ choices: [choice],
159
+ usage: anthropicBody.usage ? {
160
+ prompt_tokens: anthropicBody.usage.input_tokens,
161
+ completion_tokens: anthropicBody.usage.output_tokens,
162
+ total_tokens: (anthropicBody.usage.input_tokens || 0) + (anthropicBody.usage.output_tokens || 0),
163
+ } : undefined,
164
+ };
165
+ }
166
+
167
+ function mapStopReason(reason) {
168
+ if (!reason) return null;
169
+ if (reason === 'end_turn') return 'stop';
170
+ if (reason === 'max_tokens') return 'length';
171
+ if (reason === 'tool_use') return 'tool_calls';
172
+ if (reason === 'stop_sequence') return 'stop';
173
+ return reason;
174
+ }
175
+
176
+ // ==================== SSE 流式转换 ====================
177
+
178
+ function createSSEConverter(targetModel) {
179
+ const state = {
180
+ messageId: null,
181
+ blockType: null,
182
+ blockIndex: 0,
183
+ toolUseId: null,
184
+ toolName: null,
185
+ sentRole: false,
186
+ sentToolInit: false,
187
+ buffer: '',
188
+ };
189
+
190
+ return {
191
+ convertChunk(chunkText) {
192
+ let output = '';
193
+ state.buffer += chunkText;
194
+ const lines = state.buffer.split('\n');
195
+ state.buffer = lines.pop() || ''; // 保留不完整的最后一行
196
+
197
+ for (const line of lines) {
198
+ const converted = processLine(line.trim(), state, targetModel);
199
+ if (converted) output += converted;
200
+ }
201
+
202
+ return output;
203
+ },
204
+ flush() {
205
+ // 结束时不发送额外内容,[DONE] 在 finish_reason 时已经发送
206
+ return '';
207
+ },
208
+ };
209
+ }
210
+
211
+ function processLine(line, state, targetModel) {
212
+ if (!line.startsWith('data:')) return '';
213
+ const dataStr = line.slice(5).trim();
214
+ if (dataStr === '[DONE]') return '';
215
+
216
+ let event;
217
+ try {
218
+ event = JSON.parse(dataStr);
219
+ } catch {
220
+ return '';
221
+ }
222
+
223
+ if (!event || !event.type) return '';
224
+
225
+ switch (event.type) {
226
+ case 'message_start': {
227
+ state.messageId = event.message?.id;
228
+ state.sentRole = false;
229
+ return '';
230
+ }
231
+
232
+ case 'content_block_start': {
233
+ state.blockType = event.content_block?.type;
234
+ state.blockIndex = event.index;
235
+ state.sentToolInit = false;
236
+
237
+ if (state.blockType === 'tool_use') {
238
+ state.toolUseId = event.content_block.id;
239
+ state.toolName = event.content_block.name;
240
+ }
241
+ return '';
242
+ }
243
+
244
+ case 'content_block_delta': {
245
+ const delta = event.delta;
246
+ if (!delta) return '';
247
+
248
+ // 发送 role(如果是第一个内容块)
249
+ let prefix = '';
250
+ if (!state.sentRole) {
251
+ prefix = encodeOpenAIChunk(state.messageId, targetModel, { role: 'assistant' });
252
+ state.sentRole = true;
253
+ }
254
+
255
+ if (delta.type === 'text_delta' && delta.text) {
256
+ return prefix + encodeOpenAIChunk(state.messageId, targetModel, { content: delta.text });
257
+ }
258
+
259
+ if (delta.type === 'input_json_delta' && delta.partial_json !== undefined) {
260
+ if (!state.sentToolInit) {
261
+ state.sentToolInit = true;
262
+ const toolCallChunk = {
263
+ tool_calls: [{
264
+ index: 0,
265
+ id: state.toolUseId,
266
+ type: 'function',
267
+ function: { name: state.toolName, arguments: delta.partial_json },
268
+ }],
269
+ };
270
+ return prefix + encodeOpenAIChunk(state.messageId, targetModel, toolCallChunk);
271
+ }
272
+ return prefix + encodeOpenAIChunk(state.messageId, targetModel, {
273
+ tool_calls: [{ index: 0, function: { arguments: delta.partial_json } }],
274
+ });
275
+ }
276
+
277
+ return prefix;
278
+ }
279
+
280
+ case 'content_block_stop': {
281
+ state.blockType = null;
282
+ return '';
283
+ }
284
+
285
+ case 'message_delta': {
286
+ const stopReason = event.delta?.stop_reason;
287
+ if (stopReason) {
288
+ return encodeOpenAIChunk(state.messageId, targetModel, {}, mapStopReason(stopReason));
289
+ }
290
+ return '';
291
+ }
292
+
293
+ case 'message_stop': {
294
+ return 'data: [DONE]\n\n';
295
+ }
296
+
297
+ default:
298
+ return '';
299
+ }
300
+ }
301
+
302
+ function encodeOpenAIChunk(id, model, delta, finishReason = null) {
303
+ const chunk = {
304
+ id: id || 'chatcmpl-proxy',
305
+ object: 'chat.completion.chunk',
306
+ created: Math.floor(Date.now() / 1000),
307
+ model: model || 'proxy-model',
308
+ choices: [{
309
+ index: 0,
310
+ delta,
311
+ finish_reason: finishReason,
312
+ }],
313
+ };
314
+ return `data: ${JSON.stringify(chunk)}\n\n`;
315
+ }
316
+
317
+ module.exports = {
318
+ convertRequest,
319
+ convertResponse,
320
+ createSSEConverter,
321
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * SSE 解析与编码辅助函数
3
+ */
4
+
5
+ function parseSSELines(buffer) {
6
+ const lines = buffer.split('\n');
7
+ const events = [];
8
+ let currentEvent = { event: null, data: null };
9
+
10
+ for (const line of lines) {
11
+ const trimmed = line.trim();
12
+ if (trimmed === '') {
13
+ // 空行表示一个事件结束
14
+ if (currentEvent.data !== null) {
15
+ events.push(currentEvent);
16
+ }
17
+ currentEvent = { event: null, data: null };
18
+ continue;
19
+ }
20
+ if (trimmed.startsWith('event:')) {
21
+ currentEvent.event = trimmed.slice(6).trim();
22
+ } else if (trimmed.startsWith('data:')) {
23
+ const data = trimmed.slice(5).trim();
24
+ currentEvent.data = currentEvent.data === null ? data : currentEvent.data + '\n' + data;
25
+ }
26
+ }
27
+
28
+ // 如果最后一行没有空行结束,保留未完成的事件
29
+ const remainder = currentEvent.data !== null ? currentEvent : null;
30
+
31
+ return { events, remainder };
32
+ }
33
+
34
+ function encodeOpenAIEvent(obj) {
35
+ return `data: ${JSON.stringify(obj)}\n\n`;
36
+ }
37
+
38
+ function encodeOpenAIDone() {
39
+ return 'data: [DONE]\n\n';
40
+ }
41
+
42
+ function encodeAnthropicEvent(eventName, obj) {
43
+ return `event: ${eventName}\ndata: ${JSON.stringify(obj)}\n\n`;
44
+ }
45
+
46
+ module.exports = {
47
+ parseSSELines,
48
+ encodeOpenAIEvent,
49
+ encodeOpenAIDone,
50
+ encodeAnthropicEvent,
51
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 检测入站请求使用的协议类型
3
+ */
4
+
5
+ function detectInboundProtocol(req, body) {
6
+ const path = req.path || req.url || '';
7
+
8
+ // Anthropic 特征路径
9
+ if (path.includes('/v1/messages')) {
10
+ return 'anthropic';
11
+ }
12
+
13
+ // OpenAI 特征路径
14
+ if (path.includes('/v1/chat/completions')) {
15
+ return 'openai';
16
+ }
17
+
18
+ // 根据 body 结构推断
19
+ if (body && typeof body === 'object') {
20
+ // Anthropic: 有 system 顶级字段,messages 中角色没有 system
21
+ if (body.system !== undefined && Array.isArray(body.messages)) {
22
+ return 'anthropic';
23
+ }
24
+ // OpenAI: messages 数组中包含 role: system
25
+ if (Array.isArray(body.messages) && body.messages.some(m => m && m.role === 'system')) {
26
+ return 'openai';
27
+ }
28
+ // 默认按 functions/tools 字段判断
29
+ if (body.functions !== undefined || (body.tools && Array.isArray(body.tools))) {
30
+ return 'openai';
31
+ }
32
+ }
33
+
34
+ // 无法确定时,默认 openai
35
+ return 'openai';
36
+ }
37
+
38
+ module.exports = { detectInboundProtocol };
@@ -0,0 +1,79 @@
1
+ const { createProxyApp } = require('./proxy-server');
2
+
3
+ class ProxyManager {
4
+ constructor() {
5
+ this.servers = new Map(); // id -> { app, server, config }
6
+ }
7
+
8
+ async startProxy(proxyConfig) {
9
+ const id = proxyConfig.id;
10
+
11
+ // 如果已存在,先停止
12
+ if (this.servers.has(id)) {
13
+ await this.stopProxy(id);
14
+ }
15
+
16
+ return new Promise((resolve, reject) => {
17
+ const entry = { app: null, server: null, config: proxyConfig };
18
+ const app = createProxyApp(() => entry.config);
19
+ const server = app.listen(proxyConfig.port, () => {
20
+ console.log(`[Proxy] ${proxyConfig.name} started on port ${proxyConfig.port}`);
21
+ entry.app = app;
22
+ entry.server = server;
23
+ this.servers.set(id, entry);
24
+ resolve(true);
25
+ });
26
+
27
+ server.on('error', (err) => {
28
+ console.error(`[Proxy] ${proxyConfig.name} error:`, err.message);
29
+ this.servers.delete(id);
30
+ reject(err);
31
+ });
32
+ });
33
+ }
34
+
35
+ async stopProxy(id) {
36
+ const entry = this.servers.get(id);
37
+ if (!entry) return false;
38
+
39
+ return new Promise((resolve) => {
40
+ entry.server.close(() => {
41
+ console.log(`[Proxy] ${entry.config.name} stopped on port ${entry.config.port}`);
42
+ resolve(true);
43
+ });
44
+ this.servers.delete(id);
45
+ });
46
+ }
47
+
48
+ async restartProxy(proxyConfig) {
49
+ await this.stopProxy(proxyConfig.id);
50
+ return this.startProxy(proxyConfig);
51
+ }
52
+
53
+ updateProxyConfig(proxyConfig) {
54
+ const entry = this.servers.get(proxyConfig.id);
55
+ if (!entry) return false;
56
+ entry.config = proxyConfig;
57
+ return true;
58
+ }
59
+
60
+ isRunning(id) {
61
+ return this.servers.has(id);
62
+ }
63
+
64
+ getRunningPorts() {
65
+ return Array.from(this.servers.values()).map(s => ({
66
+ id: s.config.id,
67
+ name: s.config.name,
68
+ port: s.config.port,
69
+ }));
70
+ }
71
+
72
+ async stopAll() {
73
+ for (const [id] of this.servers) {
74
+ await this.stopProxy(id);
75
+ }
76
+ }
77
+ }
78
+
79
+ module.exports = new ProxyManager();
@@ -0,0 +1,216 @@
1
+ const express = require('express');
2
+ const { detectInboundProtocol } = require('./detector');
3
+ const o2a = require('./converters/openai-to-anthropic');
4
+ const a2o = require('./converters/anthropic-to-openai');
5
+
6
+ function createProxyApp(proxyConfigOrGetter) {
7
+ const getProxyConfig = typeof proxyConfigOrGetter === 'function'
8
+ ? proxyConfigOrGetter
9
+ : () => proxyConfigOrGetter;
10
+ const app = express();
11
+ app.use(express.json({ limit: '50mb' }));
12
+
13
+ // reasoning_content 缓存(用于 DeepSeek 等 reasoning model)
14
+ // key: assistant message content, value: reasoning_content
15
+ const reasoningCache = new Map();
16
+ const MAX_CACHE_SIZE = 100;
17
+
18
+ function setReasoning(content, reasoning) {
19
+ if (!content || !reasoning) return;
20
+ if (reasoningCache.size >= MAX_CACHE_SIZE) {
21
+ const firstKey = reasoningCache.keys().next().value;
22
+ reasoningCache.delete(firstKey);
23
+ }
24
+ reasoningCache.set(content, reasoning);
25
+ }
26
+
27
+ function getReasoning(content) {
28
+ return reasoningCache.get(content);
29
+ }
30
+
31
+ function injectReasoningToMessages(messages) {
32
+ if (!Array.isArray(messages)) return;
33
+ for (const msg of messages) {
34
+ if (msg.role === 'assistant') {
35
+ const reasoning = getReasoning(msg.content);
36
+ // DeepSeek 等 reasoning model 要求 assistant message 必须包含 reasoning_content 字段
37
+ msg.reasoning_content = reasoning || '';
38
+ }
39
+ }
40
+ }
41
+
42
+ function extractReasoningFromResponse(body) {
43
+ const choice = body.choices?.[0];
44
+ const message = choice?.message;
45
+ if (message?.role === 'assistant' && message.reasoning_content) {
46
+ setReasoning(message.content, message.reasoning_content);
47
+ }
48
+ }
49
+
50
+ app.use((req, res, next) => {
51
+ res.header('Access-Control-Allow-Origin', '*');
52
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
53
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key');
54
+ if (req.method === 'OPTIONS') return res.sendStatus(200);
55
+ next();
56
+ });
57
+
58
+ app.use((req, res, next) => {
59
+ const proxyConfig = getProxyConfig();
60
+ if (!proxyConfig.requireAuth || !proxyConfig.authToken) {
61
+ return next();
62
+ }
63
+
64
+ const token = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'];
65
+ if (token !== proxyConfig.authToken) {
66
+ return res.status(401).json({ error: 'Unauthorized' });
67
+ }
68
+ next();
69
+ });
70
+
71
+ app.post('/v1/chat/completions', handleRequest);
72
+ app.post('/v1/messages', handleRequest);
73
+
74
+ async function handleRequest(req, res) {
75
+ const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
76
+ try {
77
+ const proxyConfig = getProxyConfig();
78
+ const inboundProtocol = detectInboundProtocol(req, req.body);
79
+ const target = proxyConfig.target;
80
+
81
+ if (!target) {
82
+ return res.status(500).json({ error: 'Proxy target not configured' });
83
+ }
84
+
85
+ const targetProtocol = target.protocol;
86
+ const isStream = req.body?.stream === true;
87
+
88
+ console.log(`[${requestId}] ⬅️ ${inboundProtocol.toUpperCase()} → ${targetProtocol.toUpperCase()} | path=${req.path}`);
89
+
90
+ // 决定转换方向
91
+ let convertReq, convertRes, createSSEConv;
92
+ if (inboundProtocol === 'openai' && targetProtocol === 'anthropic') {
93
+ convertReq = o2a.convertRequest;
94
+ convertRes = o2a.convertResponse;
95
+ createSSEConv = o2a.createSSEConverter;
96
+ } else if (inboundProtocol === 'anthropic' && targetProtocol === 'openai') {
97
+ convertReq = a2o.convertRequest;
98
+ convertRes = a2o.convertResponse;
99
+ createSSEConv = a2o.createSSEConverter;
100
+ } else {
101
+ convertReq = (body, model) => ({ ...body, model: body.model || model });
102
+ convertRes = (body) => body;
103
+ createSSEConv = null;
104
+ }
105
+
106
+ // 如果请求没有 model,注入默认 model
107
+ const inboundModel = req.body?.model;
108
+ const effectiveModel = target.defaultModel || inboundModel;
109
+ if (effectiveModel) {
110
+ req.body = { ...req.body, model: effectiveModel };
111
+ }
112
+
113
+ const targetBody = convertReq(req.body, effectiveModel);
114
+
115
+ // 注入 reasoning_content(针对 DeepSeek 等 reasoning model)
116
+ injectReasoningToMessages(targetBody.messages);
117
+
118
+ // 构建目标 URL
119
+ const targetUrl = buildTargetUrl(target.providerUrl, targetProtocol, req.path);
120
+ console.log(`[${requestId}] 🔗 ${targetUrl} | model=${effectiveModel}`);
121
+
122
+ // 构建请求头
123
+ const headers = {
124
+ 'Content-Type': 'application/json',
125
+ 'Accept': isStream ? 'text/event-stream' : 'application/json',
126
+ };
127
+
128
+ if (targetProtocol === 'openai') {
129
+ headers['Authorization'] = `Bearer ${target.apiKey}`;
130
+ } else if (targetProtocol === 'anthropic') {
131
+ headers['X-Api-Key'] = target.apiKey;
132
+ headers['Anthropic-Version'] = '2023-06-01';
133
+ headers['Authorization'] = `Bearer ${target.apiKey}`;
134
+ }
135
+
136
+ const fetchRes = await fetch(targetUrl, {
137
+ method: 'POST',
138
+ headers,
139
+ body: JSON.stringify(targetBody),
140
+ signal: AbortSignal.timeout(120000),
141
+ });
142
+
143
+ if (!fetchRes.ok) {
144
+ const errBody = await fetchRes.text();
145
+ console.log(`[${requestId}] ❌ Target error: HTTP ${fetchRes.status} | ${errBody.slice(0, 500)}`);
146
+ res.status(fetchRes.status);
147
+ res.set('Content-Type', fetchRes.headers.get('content-type') || 'application/json');
148
+ return res.send(errBody);
149
+ }
150
+
151
+ // 流式响应
152
+ if (isStream && fetchRes.headers.get('content-type')?.includes('text/event-stream')) {
153
+ res.setHeader('Content-Type', 'text/event-stream');
154
+ res.setHeader('Cache-Control', 'no-cache');
155
+ res.setHeader('Connection', 'keep-alive');
156
+
157
+ const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
158
+ const reader = fetchRes.body.getReader();
159
+ const decoder = new TextDecoder();
160
+
161
+ try {
162
+ while (true) {
163
+ const { done, value } = await reader.read();
164
+ if (done) break;
165
+ const chunk = decoder.decode(value, { stream: true });
166
+ if (sseConverter) {
167
+ const converted = sseConverter.convertChunk(chunk);
168
+ if (converted) res.write(converted);
169
+ } else {
170
+ res.write(chunk);
171
+ }
172
+ }
173
+ if (sseConverter) {
174
+ const flushed = sseConverter.flush();
175
+ if (flushed) res.write(flushed);
176
+ }
177
+ } catch (err) {
178
+ console.error(`[${requestId}] Stream error:`, err.message);
179
+ } finally {
180
+ res.end();
181
+ }
182
+ return;
183
+ }
184
+
185
+ const responseBody = await fetchRes.json();
186
+ extractReasoningFromResponse(responseBody);
187
+ const convertedBody = convertRes(responseBody);
188
+ res.json(convertedBody);
189
+
190
+ } catch (err) {
191
+ console.error(`[${requestId}] ❌ Proxy error:`, err.message);
192
+ res.status(500).json({ error: 'Proxy error', message: err.message });
193
+ }
194
+ }
195
+
196
+ return app;
197
+ }
198
+
199
+ function buildTargetUrl(providerUrl, targetProtocol, originalPath) {
200
+ const base = providerUrl.replace(/\/$/, '');
201
+ const hasV1Suffix = base.endsWith('/v1');
202
+
203
+ if (targetProtocol === 'openai') {
204
+ if (hasV1Suffix) return `${base}/chat/completions`;
205
+ return `${base}/v1/chat/completions`;
206
+ }
207
+
208
+ if (targetProtocol === 'anthropic') {
209
+ if (hasV1Suffix) return `${base}/messages`;
210
+ return `${base}/v1/messages`;
211
+ }
212
+
213
+ return base + originalPath;
214
+ }
215
+
216
+ module.exports = { createProxyApp };