coding-tool-x 3.5.13 → 3.6.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,1259 @@
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const crypto = require('crypto');
4
+ const { HttpsProxyAgent } = require('https-proxy-agent');
5
+ const { broadcastLog } = require('../websocket-server');
6
+ const { recordRequest } = require('./statistics-service');
7
+ const { recordSuccess, recordFailure } = require('./channel-health');
8
+ const { publishUsageLog, publishFailureLog } = require('./proxy-log-helper');
9
+ const { createDecodedStream } = require('./response-decoder');
10
+ const { parseNonStreamingUsage } = require('./base/response-usage-parser');
11
+ const { convertClaudeToOpenCodePayload } = require('./opencode-gateway-converter');
12
+
13
+ const REQUEST_TIMEOUT_MS = 120000;
14
+
15
+ function getRequestPathname(urlPath = '') {
16
+ try {
17
+ const parsed = new URL(urlPath, 'http://localhost');
18
+ return parsed.pathname || '/';
19
+ } catch {
20
+ return String(urlPath || '').split('?')[0] || '/';
21
+ }
22
+ }
23
+
24
+ function isClaudeMessagesPath(pathname) {
25
+ return pathname === '/v1/messages' || pathname === '/messages';
26
+ }
27
+
28
+ function normalizeClaudeTargetApi(value) {
29
+ const normalized = String(value || '').trim().toLowerCase();
30
+ if (normalized === 'chat' || normalized === 'chat/completions' || normalized === 'chat.completions') {
31
+ return 'chat.completions';
32
+ }
33
+ return 'responses';
34
+ }
35
+
36
+ function isOfficialOpenAiBaseUrl(baseUrl = '') {
37
+ try {
38
+ const parsed = new URL(String(baseUrl || '').trim());
39
+ return String(parsed.hostname || '').trim().toLowerCase() === 'api.openai.com';
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ function resolveClaudeGatewayTargetApi(channel = {}) {
46
+ const configured = normalizeClaudeTargetApi(channel?.targetApi);
47
+ if (configured === 'responses' && !isOfficialOpenAiBaseUrl(channel?.baseUrl)) {
48
+ return 'chat.completions';
49
+ }
50
+ return configured;
51
+ }
52
+
53
+ function buildOpenAiTargetUrl(baseUrl = '', endpoint = '/v1/responses') {
54
+ const trimmedBaseUrl = String(baseUrl || '').trim().replace(/\/+$/, '');
55
+ if (!trimmedBaseUrl) {
56
+ throw new Error('Invalid baseUrl');
57
+ }
58
+
59
+ const normalizedEndpoint = String(endpoint || '/v1/responses').startsWith('/')
60
+ ? String(endpoint || '/v1/responses')
61
+ : `/${endpoint}`;
62
+
63
+ if (trimmedBaseUrl.endsWith(normalizedEndpoint)) {
64
+ return trimmedBaseUrl;
65
+ }
66
+
67
+ const endpointWithoutV1 = normalizedEndpoint.startsWith('/v1/')
68
+ ? normalizedEndpoint.slice(3)
69
+ : normalizedEndpoint;
70
+
71
+ if (trimmedBaseUrl.endsWith(endpointWithoutV1)) {
72
+ return trimmedBaseUrl;
73
+ }
74
+
75
+ if (trimmedBaseUrl.endsWith('/v1') && normalizedEndpoint.startsWith('/v1/')) {
76
+ return `${trimmedBaseUrl}${normalizedEndpoint.slice(3)}`;
77
+ }
78
+
79
+ return `${trimmedBaseUrl}${normalizedEndpoint}`;
80
+ }
81
+
82
+ function buildOpenAiHeaders(apiKey, options = {}) {
83
+ const wantsStream = options.wantsStream === true;
84
+ const targetApi = String(options.targetApi || 'responses').trim().toLowerCase();
85
+ const headers = {
86
+ authorization: `Bearer ${apiKey}`,
87
+ 'content-type': 'application/json',
88
+ accept: wantsStream ? 'text/event-stream' : 'application/json',
89
+ 'accept-encoding': 'gzip, deflate, br',
90
+ connection: 'keep-alive',
91
+ 'user-agent': 'Coding-Tool-ClaudeGateway/1.0'
92
+ };
93
+
94
+ if (targetApi === 'responses') {
95
+ headers['openai-beta'] = 'responses=experimental';
96
+ }
97
+
98
+ return headers;
99
+ }
100
+
101
+ function buildProxyAgent(proxyUrl = '') {
102
+ if (typeof proxyUrl !== 'string' || !proxyUrl.trim()) {
103
+ return undefined;
104
+ }
105
+ return new HttpsProxyAgent(proxyUrl.trim());
106
+ }
107
+
108
+ function collectHttpResponseBody(response) {
109
+ return new Promise((resolve, reject) => {
110
+ const stream = createDecodedStream(response);
111
+ const chunks = [];
112
+ stream.on('data', (chunk) => {
113
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
114
+ });
115
+ stream.on('end', () => {
116
+ resolve(Buffer.concat(chunks).toString('utf8'));
117
+ });
118
+ stream.on('error', reject);
119
+ response.on('error', reject);
120
+ });
121
+ }
122
+
123
+ function postJson(url, headers, payload, timeoutMs = REQUEST_TIMEOUT_MS, agent) {
124
+ return new Promise((resolve, reject) => {
125
+ const target = new URL(url);
126
+ const isHttps = target.protocol === 'https:';
127
+ const client = isHttps ? https : http;
128
+ const body = JSON.stringify(payload || {});
129
+ const request = client.request({
130
+ hostname: target.hostname,
131
+ port: target.port || (isHttps ? 443 : 80),
132
+ path: `${target.pathname}${target.search}`,
133
+ method: 'POST',
134
+ timeout: timeoutMs,
135
+ agent,
136
+ headers: {
137
+ ...headers,
138
+ 'Content-Length': Buffer.byteLength(body)
139
+ }
140
+ }, (response) => {
141
+ collectHttpResponseBody(response)
142
+ .then((rawBody) => {
143
+ resolve({
144
+ statusCode: Number(response.statusCode) || 500,
145
+ headers: response.headers || {},
146
+ rawBody
147
+ });
148
+ })
149
+ .catch(reject);
150
+ });
151
+
152
+ request.on('error', reject);
153
+ request.on('timeout', () => {
154
+ request.destroy(new Error('OpenAI gateway request timeout'));
155
+ });
156
+ request.write(body);
157
+ request.end();
158
+ });
159
+ }
160
+
161
+ function postJsonStream(url, headers, payload, timeoutMs = REQUEST_TIMEOUT_MS, agent) {
162
+ return new Promise((resolve, reject) => {
163
+ const target = new URL(url);
164
+ const isHttps = target.protocol === 'https:';
165
+ const client = isHttps ? https : http;
166
+ const body = JSON.stringify(payload || {});
167
+ const request = client.request({
168
+ hostname: target.hostname,
169
+ port: target.port || (isHttps ? 443 : 80),
170
+ path: `${target.pathname}${target.search}`,
171
+ method: 'POST',
172
+ timeout: timeoutMs,
173
+ agent,
174
+ headers: {
175
+ ...headers,
176
+ 'Content-Length': Buffer.byteLength(body)
177
+ }
178
+ }, (response) => {
179
+ resolve({
180
+ statusCode: Number(response.statusCode) || 500,
181
+ headers: response.headers || {},
182
+ response
183
+ });
184
+ });
185
+
186
+ request.on('error', reject);
187
+ request.on('timeout', () => {
188
+ request.destroy(new Error('OpenAI gateway request timeout'));
189
+ });
190
+ request.write(body);
191
+ request.end();
192
+ });
193
+ }
194
+
195
+ function safeJsonParse(value) {
196
+ try {
197
+ return JSON.parse(value);
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ function mapStatusCodeToAnthropicErrorType(statusCode) {
204
+ if (statusCode === 401 || statusCode === 403) return 'authentication_error';
205
+ if (statusCode === 429) return 'rate_limit_error';
206
+ if (statusCode >= 500) return 'api_error';
207
+ return 'invalid_request_error';
208
+ }
209
+
210
+ function sendAnthropicError(res, statusCode, message) {
211
+ if (res.headersSent) return;
212
+ res.status(statusCode).json({
213
+ type: 'error',
214
+ error: {
215
+ type: mapStatusCodeToAnthropicErrorType(statusCode),
216
+ message
217
+ }
218
+ });
219
+ }
220
+
221
+ function collectTextFragments(value, fragments) {
222
+ if (value === null || value === undefined) return;
223
+
224
+ if (typeof value === 'string') {
225
+ if (value) {
226
+ fragments.push(value);
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (typeof value === 'number' || typeof value === 'boolean') {
232
+ fragments.push(String(value));
233
+ return;
234
+ }
235
+
236
+ if (Array.isArray(value)) {
237
+ value.forEach((item) => collectTextFragments(item, fragments));
238
+ return;
239
+ }
240
+
241
+ if (typeof value !== 'object') {
242
+ return;
243
+ }
244
+
245
+ if (typeof value.text === 'string') {
246
+ collectTextFragments(value.text, fragments);
247
+ return;
248
+ }
249
+ if (typeof value.input_text === 'string') {
250
+ collectTextFragments(value.input_text, fragments);
251
+ return;
252
+ }
253
+ if (typeof value.output_text === 'string') {
254
+ collectTextFragments(value.output_text, fragments);
255
+ return;
256
+ }
257
+ if (value.content !== undefined) {
258
+ collectTextFragments(value.content, fragments);
259
+ return;
260
+ }
261
+ if (Array.isArray(value.parts)) {
262
+ collectTextFragments(value.parts, fragments);
263
+ }
264
+ }
265
+
266
+ function extractText(value) {
267
+ const fragments = [];
268
+ collectTextFragments(value, fragments);
269
+ return fragments.join('\n').trim();
270
+ }
271
+
272
+ function normalizeToolArguments(value) {
273
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
274
+ return value;
275
+ }
276
+
277
+ if (typeof value === 'string') {
278
+ const trimmed = value.trim();
279
+ if (!trimmed) return {};
280
+ try {
281
+ const parsed = JSON.parse(trimmed);
282
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
283
+ return parsed;
284
+ }
285
+ } catch {
286
+ return {};
287
+ }
288
+ }
289
+
290
+ return {};
291
+ }
292
+
293
+ function buildClaudeUsageFromTokens(tokens = {}) {
294
+ return {
295
+ input_tokens: Number(tokens.input || 0),
296
+ output_tokens: Number(tokens.output || 0),
297
+ cache_creation_input_tokens: Number(tokens.cacheCreation || 0),
298
+ cache_read_input_tokens: Number(tokens.cacheRead || tokens.cached || 0)
299
+ };
300
+ }
301
+
302
+ function buildPublishTokensFromClaudeUsage(usage = {}) {
303
+ return {
304
+ input: Number(usage.input_tokens || 0),
305
+ output: Number(usage.output_tokens || 0),
306
+ cacheCreation: Number(usage.cache_creation_input_tokens || 0),
307
+ cacheRead: Number(usage.cache_read_input_tokens || 0),
308
+ cached: Number(usage.cache_read_input_tokens || 0),
309
+ reasoning: 0,
310
+ total: 0
311
+ };
312
+ }
313
+
314
+ function mapResponsesStopReason(response, hasToolUse) {
315
+ if (hasToolUse) return 'tool_use';
316
+
317
+ const incompleteReason = String(
318
+ response?.incomplete_details?.reason
319
+ || response?.incompleteDetails?.reason
320
+ || ''
321
+ ).trim().toLowerCase();
322
+
323
+ if (incompleteReason === 'max_output_tokens' || incompleteReason === 'max_tokens') {
324
+ return 'max_tokens';
325
+ }
326
+
327
+ return 'end_turn';
328
+ }
329
+
330
+ function mapChatFinishReason(finishReason, hasToolUse) {
331
+ if (hasToolUse || finishReason === 'tool_calls') {
332
+ return 'tool_use';
333
+ }
334
+ if (finishReason === 'length') {
335
+ return 'max_tokens';
336
+ }
337
+ return 'end_turn';
338
+ }
339
+
340
+ function buildClaudeContentFromResponses(response = {}) {
341
+ const content = [];
342
+ let hasToolUse = false;
343
+ const output = Array.isArray(response.output) ? response.output : [];
344
+
345
+ output.forEach((item) => {
346
+ if (!item || typeof item !== 'object') return;
347
+
348
+ if (item.type === 'message') {
349
+ const parts = Array.isArray(item.content) ? item.content : [];
350
+ parts.forEach((part) => {
351
+ const text = extractText(part);
352
+ if (text) {
353
+ content.push({
354
+ type: 'text',
355
+ text
356
+ });
357
+ }
358
+ });
359
+ return;
360
+ }
361
+
362
+ if (item.type === 'function_call' && item.name) {
363
+ hasToolUse = true;
364
+ content.push({
365
+ type: 'tool_use',
366
+ id: String(item.call_id || item.id || `toolu_${Date.now()}`),
367
+ name: item.name,
368
+ input: normalizeToolArguments(item.arguments)
369
+ });
370
+ }
371
+ });
372
+
373
+ return { content, hasToolUse };
374
+ }
375
+
376
+ function buildClaudeContentFromChat(chat = {}) {
377
+ const content = [];
378
+ const choice = Array.isArray(chat.choices) ? chat.choices[0] : null;
379
+ const message = choice?.message && typeof choice.message === 'object' ? choice.message : {};
380
+ const text = extractText(message.content);
381
+ let hasToolUse = false;
382
+
383
+ if (text) {
384
+ content.push({
385
+ type: 'text',
386
+ text
387
+ });
388
+ }
389
+
390
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
391
+ toolCalls.forEach((toolCall) => {
392
+ const functionPayload = (toolCall?.function && typeof toolCall.function === 'object')
393
+ ? toolCall.function
394
+ : toolCall;
395
+ const name = String(functionPayload?.name || '').trim();
396
+ if (!name) return;
397
+ hasToolUse = true;
398
+ content.push({
399
+ type: 'tool_use',
400
+ id: String(toolCall?.id || toolCall?.call_id || `toolu_${Date.now()}`),
401
+ name,
402
+ input: normalizeToolArguments(functionPayload.arguments)
403
+ });
404
+ });
405
+
406
+ return {
407
+ content,
408
+ hasToolUse,
409
+ finishReason: choice?.finish_reason || 'stop'
410
+ };
411
+ }
412
+
413
+ function buildClaudeMessageFromResponses(response = {}, fallbackModel = '') {
414
+ const parsedUsage = parseNonStreamingUsage(response);
415
+ const { content, hasToolUse } = buildClaudeContentFromResponses(response);
416
+
417
+ return {
418
+ id: String(response.id || `msg_${Date.now()}`),
419
+ type: 'message',
420
+ role: 'assistant',
421
+ model: parsedUsage.model || response.model || fallbackModel || '',
422
+ content,
423
+ stop_reason: mapResponsesStopReason(response, hasToolUse),
424
+ stop_sequence: null,
425
+ usage: buildClaudeUsageFromTokens(parsedUsage.tokens || {})
426
+ };
427
+ }
428
+
429
+ function buildClaudeMessageFromChat(chat = {}, fallbackModel = '') {
430
+ const parsedUsage = parseNonStreamingUsage(chat);
431
+ const { content, hasToolUse, finishReason } = buildClaudeContentFromChat(chat);
432
+
433
+ return {
434
+ id: String(chat.id || `msg_${Date.now()}`),
435
+ type: 'message',
436
+ role: 'assistant',
437
+ model: parsedUsage.model || chat.model || fallbackModel || '',
438
+ content,
439
+ stop_reason: mapChatFinishReason(finishReason, hasToolUse),
440
+ stop_sequence: null,
441
+ usage: buildClaudeUsageFromTokens(parsedUsage.tokens || {})
442
+ };
443
+ }
444
+
445
+ function createClaudeSseState(fallbackModel = '') {
446
+ return {
447
+ started: false,
448
+ finalized: false,
449
+ model: fallbackModel || '',
450
+ messageId: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
451
+ nextBlockIndex: 0,
452
+ textBlocks: new Map(),
453
+ toolBlocks: new Map(),
454
+ usageTokens: null,
455
+ stopReason: 'end_turn',
456
+ completedMessage: null
457
+ };
458
+ }
459
+
460
+ function ensureSseHeaders(res) {
461
+ if (res.headersSent) return;
462
+ res.status(200);
463
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
464
+ res.setHeader('Cache-Control', 'no-cache');
465
+ res.setHeader('Connection', 'keep-alive');
466
+ }
467
+
468
+ function writeClaudeEvent(res, eventName, payload) {
469
+ ensureSseHeaders(res);
470
+ res.write(`event: ${eventName}\n`);
471
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
472
+ }
473
+
474
+ function ensureClaudeMessageStart(res, state) {
475
+ if (state.started) return;
476
+ state.started = true;
477
+ writeClaudeEvent(res, 'message_start', {
478
+ type: 'message_start',
479
+ message: {
480
+ id: state.messageId,
481
+ type: 'message',
482
+ role: 'assistant',
483
+ model: state.model || '',
484
+ content: [],
485
+ stop_reason: null,
486
+ stop_sequence: null,
487
+ usage: buildClaudeUsageFromTokens(state.usageTokens || {})
488
+ }
489
+ });
490
+ }
491
+
492
+ function startClaudeTextBlock(res, state, key) {
493
+ const normalizedKey = String(key);
494
+ let entry = state.textBlocks.get(normalizedKey);
495
+ if (entry && entry.open) {
496
+ return entry;
497
+ }
498
+
499
+ ensureClaudeMessageStart(res, state);
500
+ entry = entry || {
501
+ blockIndex: state.nextBlockIndex,
502
+ sentText: ''
503
+ };
504
+ if (!Number.isFinite(entry.blockIndex)) {
505
+ entry.blockIndex = state.nextBlockIndex;
506
+ }
507
+ state.nextBlockIndex = Math.max(state.nextBlockIndex, entry.blockIndex + 1);
508
+ entry.open = true;
509
+ state.textBlocks.set(normalizedKey, entry);
510
+
511
+ writeClaudeEvent(res, 'content_block_start', {
512
+ type: 'content_block_start',
513
+ index: entry.blockIndex,
514
+ content_block: {
515
+ type: 'text',
516
+ text: ''
517
+ }
518
+ });
519
+
520
+ return entry;
521
+ }
522
+
523
+ function emitClaudeTextDelta(res, state, key, value) {
524
+ const text = typeof value === 'string' ? value : '';
525
+ if (!text) return;
526
+
527
+ const entry = startClaudeTextBlock(res, state, key);
528
+ entry.sentText = `${entry.sentText || ''}${text}`;
529
+
530
+ writeClaudeEvent(res, 'content_block_delta', {
531
+ type: 'content_block_delta',
532
+ index: entry.blockIndex,
533
+ delta: {
534
+ type: 'text_delta',
535
+ text
536
+ }
537
+ });
538
+ }
539
+
540
+ function closeClaudeTextBlock(res, state, key) {
541
+ const entry = state.textBlocks.get(String(key));
542
+ if (!entry || !entry.open) return;
543
+ entry.open = false;
544
+ writeClaudeEvent(res, 'content_block_stop', {
545
+ type: 'content_block_stop',
546
+ index: entry.blockIndex
547
+ });
548
+ }
549
+
550
+ function ensureClaudeToolBlock(res, state, key, toolState) {
551
+ if (!toolState) return null;
552
+ if (toolState.open) return toolState;
553
+ if (!toolState.name) return null;
554
+
555
+ ensureClaudeMessageStart(res, state);
556
+ if (!Number.isFinite(toolState.blockIndex)) {
557
+ toolState.blockIndex = state.nextBlockIndex;
558
+ state.nextBlockIndex += 1;
559
+ }
560
+
561
+ toolState.open = true;
562
+ state.toolBlocks.set(String(key), toolState);
563
+
564
+ writeClaudeEvent(res, 'content_block_start', {
565
+ type: 'content_block_start',
566
+ index: toolState.blockIndex,
567
+ content_block: {
568
+ type: 'tool_use',
569
+ id: toolState.id,
570
+ name: toolState.name,
571
+ input: {}
572
+ }
573
+ });
574
+
575
+ if (toolState.pendingArgs) {
576
+ const delta = toolState.pendingArgs;
577
+ toolState.pendingArgs = '';
578
+ toolState.sentArgs = `${toolState.sentArgs || ''}${delta}`;
579
+ writeClaudeEvent(res, 'content_block_delta', {
580
+ type: 'content_block_delta',
581
+ index: toolState.blockIndex,
582
+ delta: {
583
+ type: 'input_json_delta',
584
+ partial_json: delta
585
+ }
586
+ });
587
+ }
588
+
589
+ return toolState;
590
+ }
591
+
592
+ function emitClaudeToolDelta(res, state, key, toolState, value) {
593
+ const delta = typeof value === 'string' ? value : '';
594
+ if (!delta) return;
595
+
596
+ toolState.pendingArgs = `${toolState.pendingArgs || ''}${delta}`;
597
+ const activeToolState = ensureClaudeToolBlock(res, state, key, toolState);
598
+ if (!activeToolState || !activeToolState.open) return;
599
+
600
+ if (activeToolState.pendingArgs) {
601
+ const flushValue = activeToolState.pendingArgs;
602
+ activeToolState.pendingArgs = '';
603
+ activeToolState.sentArgs = `${activeToolState.sentArgs || ''}${flushValue}`;
604
+ writeClaudeEvent(res, 'content_block_delta', {
605
+ type: 'content_block_delta',
606
+ index: activeToolState.blockIndex,
607
+ delta: {
608
+ type: 'input_json_delta',
609
+ partial_json: flushValue
610
+ }
611
+ });
612
+ }
613
+ }
614
+
615
+ function closeClaudeToolBlock(res, state, key) {
616
+ const toolState = state.toolBlocks.get(String(key));
617
+ if (!toolState || !toolState.open) return;
618
+ toolState.open = false;
619
+ writeClaudeEvent(res, 'content_block_stop', {
620
+ type: 'content_block_stop',
621
+ index: toolState.blockIndex
622
+ });
623
+ }
624
+
625
+ function closeAllOpenBlocks(res, state) {
626
+ for (const key of state.textBlocks.keys()) {
627
+ closeClaudeTextBlock(res, state, key);
628
+ }
629
+ for (const key of state.toolBlocks.keys()) {
630
+ closeClaudeToolBlock(res, state, key);
631
+ }
632
+ }
633
+
634
+ function sortedStreamEntries(map) {
635
+ return Array.from(map.values())
636
+ .filter((entry) => entry && Number.isFinite(Number(entry.blockIndex)))
637
+ .sort((a, b) => Number(a.blockIndex) - Number(b.blockIndex));
638
+ }
639
+
640
+ function buildClaudeMessageFromStreamState(state) {
641
+ const content = [];
642
+ const orderedEntries = [
643
+ ...sortedStreamEntries(state.textBlocks).map((entry) => ({ kind: 'text', entry })),
644
+ ...sortedStreamEntries(state.toolBlocks).map((entry) => ({ kind: 'tool', entry }))
645
+ ].sort((a, b) => Number(a.entry.blockIndex) - Number(b.entry.blockIndex));
646
+
647
+ orderedEntries.forEach(({ kind, entry }) => {
648
+ if (kind === 'text') {
649
+ const text = typeof entry.sentText === 'string' ? entry.sentText : '';
650
+ if (!text) return;
651
+ content.push({
652
+ type: 'text',
653
+ text
654
+ });
655
+ return;
656
+ }
657
+
658
+ if (!entry || !entry.name) return;
659
+ const argumentsText = `${entry.sentArgs || ''}${entry.pendingArgs || ''}`;
660
+ content.push({
661
+ type: 'tool_use',
662
+ id: String(entry.id || `toolu_${Date.now()}`),
663
+ name: entry.name,
664
+ input: normalizeToolArguments(argumentsText)
665
+ });
666
+ });
667
+
668
+ return {
669
+ id: state.messageId,
670
+ type: 'message',
671
+ role: 'assistant',
672
+ model: state.model || '',
673
+ content,
674
+ stop_reason: state.stopReason || 'end_turn',
675
+ stop_sequence: null,
676
+ usage: buildClaudeUsageFromTokens(state.usageTokens || {})
677
+ };
678
+ }
679
+
680
+ function finalizeClaudeSse(res, state, message) {
681
+ if (state.finalized) {
682
+ return;
683
+ }
684
+
685
+ if (message?.model) {
686
+ state.model = message.model;
687
+ }
688
+ if (message?.id) {
689
+ state.messageId = message.id;
690
+ }
691
+
692
+ ensureClaudeMessageStart(res, state);
693
+ closeAllOpenBlocks(res, state);
694
+
695
+ writeClaudeEvent(res, 'message_delta', {
696
+ type: 'message_delta',
697
+ delta: {
698
+ stop_reason: message?.stop_reason || state.stopReason || 'end_turn',
699
+ stop_sequence: null
700
+ },
701
+ usage: message?.usage || buildClaudeUsageFromTokens(state.usageTokens || {})
702
+ });
703
+
704
+ writeClaudeEvent(res, 'message_stop', {
705
+ type: 'message_stop'
706
+ });
707
+
708
+ state.finalized = true;
709
+ res.end();
710
+ }
711
+
712
+ function emitFullClaudeMessageAsSse(res, state, message) {
713
+ if (!message || state.finalized) {
714
+ return;
715
+ }
716
+
717
+ state.model = message.model || state.model;
718
+ state.messageId = message.id || state.messageId;
719
+ ensureClaudeMessageStart(res, state);
720
+
721
+ (Array.isArray(message.content) ? message.content : []).forEach((block) => {
722
+ if (!block || typeof block !== 'object') return;
723
+
724
+ if (block.type === 'text') {
725
+ const key = `full-text-${state.nextBlockIndex}`;
726
+ startClaudeTextBlock(res, state, key);
727
+ if (typeof block.text === 'string' && block.text) {
728
+ emitClaudeTextDelta(res, state, key, block.text);
729
+ }
730
+ closeClaudeTextBlock(res, state, key);
731
+ return;
732
+ }
733
+
734
+ if (block.type === 'tool_use' && block.name) {
735
+ const key = `full-tool-${state.nextBlockIndex}`;
736
+ const inputJson = JSON.stringify(block.input && typeof block.input === 'object' ? block.input : {});
737
+ const toolState = {
738
+ blockIndex: state.nextBlockIndex,
739
+ id: String(block.id || `toolu_${Date.now()}`),
740
+ name: block.name,
741
+ sentArgs: '',
742
+ pendingArgs: inputJson && inputJson !== '{}' ? inputJson : ''
743
+ };
744
+ state.nextBlockIndex += 1;
745
+ state.toolBlocks.set(key, toolState);
746
+ ensureClaudeToolBlock(res, state, key, toolState);
747
+ closeClaudeToolBlock(res, state, key);
748
+ }
749
+ });
750
+
751
+ finalizeClaudeSse(res, state, message);
752
+ }
753
+
754
+ function appendMissingTextDelta(res, state, key, fullText) {
755
+ const entry = state.textBlocks.get(String(key));
756
+ const normalizedFullText = typeof fullText === 'string' ? fullText : '';
757
+ if (!normalizedFullText) return;
758
+ if (!entry) {
759
+ emitClaudeTextDelta(res, state, key, normalizedFullText);
760
+ return;
761
+ }
762
+ const previous = entry.sentText || '';
763
+ if (!normalizedFullText.startsWith(previous)) {
764
+ return;
765
+ }
766
+ const suffix = normalizedFullText.slice(previous.length);
767
+ if (suffix) {
768
+ emitClaudeTextDelta(res, state, key, suffix);
769
+ }
770
+ }
771
+
772
+ function appendMissingToolArgs(res, state, key, toolState, fullArgs) {
773
+ const normalizedArgs = typeof fullArgs === 'string' ? fullArgs : '';
774
+ if (!normalizedArgs) return;
775
+ if (!toolState) {
776
+ return;
777
+ }
778
+ const previous = toolState.sentArgs || '';
779
+ if (!normalizedArgs.startsWith(previous)) {
780
+ return;
781
+ }
782
+ const suffix = normalizedArgs.slice(previous.length);
783
+ if (suffix) {
784
+ emitClaudeToolDelta(res, state, key, toolState, suffix);
785
+ }
786
+ }
787
+
788
+ function processResponsesStreamEvent(parsed, state, res) {
789
+ if (!parsed || typeof parsed !== 'object' || state.finalized) {
790
+ return;
791
+ }
792
+
793
+ const parsedUsage = parseNonStreamingUsage(parsed);
794
+ if (parsedUsage.model) {
795
+ state.model = parsedUsage.model;
796
+ }
797
+ if (parsedUsage.tokens) {
798
+ state.usageTokens = parsedUsage.tokens;
799
+ }
800
+
801
+ if (parsed.response && typeof parsed.response === 'object') {
802
+ if (typeof parsed.response.model === 'string' && parsed.response.model.trim()) {
803
+ state.model = parsed.response.model.trim();
804
+ }
805
+ if (parsed.response.id) {
806
+ state.messageId = String(parsed.response.id);
807
+ }
808
+ }
809
+
810
+ if (parsed.type === 'response.created' || parsed.type === 'response.in_progress') {
811
+ ensureClaudeMessageStart(res, state);
812
+ return;
813
+ }
814
+
815
+ if (parsed.type === 'response.output_item.added' || parsed.type === 'response.output_item.done') {
816
+ const item = parsed.item && typeof parsed.item === 'object' ? parsed.item : {};
817
+ const outputIndex = Number.isFinite(Number(parsed.output_index)) ? Number(parsed.output_index) : 0;
818
+
819
+ if (item.type === 'message') {
820
+ const key = `response-text-${outputIndex}`;
821
+ const messageText = Array.isArray(item.content)
822
+ ? item.content.map((part) => extractText(part)).filter(Boolean).join('\n').trim()
823
+ : '';
824
+ if (messageText) {
825
+ appendMissingTextDelta(res, state, key, messageText);
826
+ }
827
+ if (parsed.type === 'response.output_item.done') {
828
+ closeClaudeTextBlock(res, state, key);
829
+ }
830
+ return;
831
+ }
832
+
833
+ if (item.type === 'function_call') {
834
+ const key = `response-tool-${outputIndex}`;
835
+ const existing = state.toolBlocks.get(key) || {
836
+ blockIndex: state.nextBlockIndex,
837
+ id: String(item.call_id || item.id || `toolu_${Date.now()}`),
838
+ name: typeof item.name === 'string' ? item.name : '',
839
+ sentArgs: '',
840
+ pendingArgs: ''
841
+ };
842
+ if (!state.toolBlocks.has(key)) {
843
+ state.nextBlockIndex += 1;
844
+ }
845
+ if (item.call_id || item.id) {
846
+ existing.id = String(item.call_id || item.id);
847
+ }
848
+ if (typeof item.name === 'string' && item.name.trim()) {
849
+ existing.name = item.name.trim();
850
+ }
851
+ state.toolBlocks.set(key, existing);
852
+ ensureClaudeToolBlock(res, state, key, existing);
853
+ appendMissingToolArgs(res, state, key, existing, item.arguments);
854
+ if (parsed.type === 'response.output_item.done') {
855
+ closeClaudeToolBlock(res, state, key);
856
+ }
857
+ }
858
+ return;
859
+ }
860
+
861
+ if (parsed.type === 'response.content_part.added') {
862
+ const outputIndex = Number.isFinite(Number(parsed.output_index)) ? Number(parsed.output_index) : 0;
863
+ const text = extractText(parsed.part);
864
+ if (text) {
865
+ appendMissingTextDelta(res, state, `response-text-${outputIndex}`, text);
866
+ }
867
+ return;
868
+ }
869
+
870
+ if (parsed.type === 'response.output_text.delta') {
871
+ const outputIndex = Number.isFinite(Number(parsed.output_index)) ? Number(parsed.output_index) : 0;
872
+ if (typeof parsed.delta === 'string' && parsed.delta) {
873
+ emitClaudeTextDelta(res, state, `response-text-${outputIndex}`, parsed.delta);
874
+ }
875
+ return;
876
+ }
877
+
878
+ if (parsed.type === 'response.function_call_arguments.delta') {
879
+ const outputIndex = Number.isFinite(Number(parsed.output_index)) ? Number(parsed.output_index) : 0;
880
+ const key = `response-tool-${outputIndex}`;
881
+ const toolState = state.toolBlocks.get(key) || {
882
+ blockIndex: state.nextBlockIndex,
883
+ id: String(parsed.item_id || `toolu_${Date.now()}`),
884
+ name: '',
885
+ sentArgs: '',
886
+ pendingArgs: ''
887
+ };
888
+ if (!state.toolBlocks.has(key)) {
889
+ state.nextBlockIndex += 1;
890
+ }
891
+ state.toolBlocks.set(key, toolState);
892
+ if (typeof parsed.delta === 'string' && parsed.delta) {
893
+ emitClaudeToolDelta(res, state, key, toolState, parsed.delta);
894
+ }
895
+ return;
896
+ }
897
+
898
+ if (parsed.type === 'response.completed' || parsed.type === 'response.incomplete') {
899
+ const completedMessage = buildClaudeMessageFromResponses(parsed.response || {}, state.model);
900
+ state.completedMessage = completedMessage;
901
+ state.stopReason = completedMessage.stop_reason;
902
+
903
+ if (!state.started) {
904
+ emitFullClaudeMessageAsSse(res, state, completedMessage);
905
+ return;
906
+ }
907
+
908
+ finalizeClaudeSse(res, state, completedMessage);
909
+ }
910
+ }
911
+
912
+ function processChatStreamChunk(parsed, state, res) {
913
+ if (!parsed || typeof parsed !== 'object' || state.finalized) {
914
+ return;
915
+ }
916
+
917
+ const parsedUsage = parseNonStreamingUsage(parsed);
918
+ if (parsedUsage.model) {
919
+ state.model = parsedUsage.model;
920
+ }
921
+ if (parsedUsage.tokens) {
922
+ state.usageTokens = parsedUsage.tokens;
923
+ }
924
+
925
+ if (typeof parsed.id === 'string' && parsed.id.trim()) {
926
+ state.messageId = parsed.id.trim();
927
+ }
928
+
929
+ const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
930
+ if (!choice || typeof choice !== 'object') {
931
+ return;
932
+ }
933
+
934
+ const delta = choice.delta && typeof choice.delta === 'object' ? choice.delta : {};
935
+
936
+ if (typeof delta.content === 'string' && delta.content) {
937
+ emitClaudeTextDelta(res, state, 'chat-text-0', delta.content);
938
+ }
939
+
940
+ const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
941
+ toolCalls.forEach((toolCall, index) => {
942
+ const toolIndex = Number.isFinite(Number(toolCall?.index)) ? Number(toolCall.index) : index;
943
+ const key = `chat-tool-${toolIndex}`;
944
+ const existing = state.toolBlocks.get(key) || {
945
+ blockIndex: state.nextBlockIndex,
946
+ id: String(toolCall?.id || `toolu_${Date.now()}_${toolIndex}`),
947
+ name: '',
948
+ sentArgs: '',
949
+ pendingArgs: ''
950
+ };
951
+ if (!state.toolBlocks.has(key)) {
952
+ state.nextBlockIndex += 1;
953
+ }
954
+
955
+ const functionPayload = (toolCall?.function && typeof toolCall.function === 'object')
956
+ ? toolCall.function
957
+ : {};
958
+ if (toolCall?.id) {
959
+ existing.id = String(toolCall.id);
960
+ }
961
+ if (typeof functionPayload.name === 'string' && functionPayload.name.trim()) {
962
+ existing.name = functionPayload.name.trim();
963
+ }
964
+
965
+ state.toolBlocks.set(key, existing);
966
+ ensureClaudeToolBlock(res, state, key, existing);
967
+
968
+ if (typeof functionPayload.arguments === 'string' && functionPayload.arguments) {
969
+ emitClaudeToolDelta(res, state, key, existing, functionPayload.arguments);
970
+ }
971
+ });
972
+
973
+ if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
974
+ state.stopReason = mapChatFinishReason(choice.finish_reason, toolCalls.length > 0);
975
+ }
976
+ }
977
+
978
+ async function relayResponsesStreamAsClaude(upstreamResponse, res, fallbackModel = '') {
979
+ const state = createClaudeSseState(fallbackModel);
980
+ const stream = createDecodedStream(upstreamResponse);
981
+ let buffer = '';
982
+
983
+ return new Promise((resolve, reject) => {
984
+ stream.on('data', (chunk) => {
985
+ buffer += chunk.toString('utf8');
986
+ const blocks = buffer.split('\n\n');
987
+ buffer = blocks.pop() || '';
988
+
989
+ blocks.forEach((block) => {
990
+ const lines = block.split(/\r?\n/);
991
+ const dataLines = lines
992
+ .map((line) => line.trim())
993
+ .filter((line) => line.startsWith('data:'))
994
+ .map((line) => line.slice(5).trim())
995
+ .filter(Boolean);
996
+
997
+ if (dataLines.length === 0) return;
998
+ const data = dataLines.join('\n');
999
+ if (data === '[DONE]') return;
1000
+ const parsed = safeJsonParse(data);
1001
+ processResponsesStreamEvent(parsed, state, res);
1002
+ });
1003
+ });
1004
+
1005
+ stream.on('end', () => {
1006
+ if (!state.finalized) {
1007
+ const parsed = safeJsonParse(buffer.trim());
1008
+ if (parsed?.output) {
1009
+ state.completedMessage = buildClaudeMessageFromResponses(parsed, state.model);
1010
+ }
1011
+
1012
+ if (state.completedMessage) {
1013
+ if (!state.started) {
1014
+ emitFullClaudeMessageAsSse(res, state, state.completedMessage);
1015
+ } else {
1016
+ finalizeClaudeSse(res, state, state.completedMessage);
1017
+ }
1018
+ } else if (state.started) {
1019
+ finalizeClaudeSse(res, state, {
1020
+ model: state.model,
1021
+ stop_reason: state.stopReason,
1022
+ usage: buildClaudeUsageFromTokens(state.usageTokens || {})
1023
+ });
1024
+ }
1025
+ }
1026
+
1027
+ resolve(state.completedMessage);
1028
+ });
1029
+
1030
+ stream.on('error', reject);
1031
+ upstreamResponse.on('error', reject);
1032
+ });
1033
+ }
1034
+
1035
+ async function relayChatStreamAsClaude(upstreamResponse, res, fallbackModel = '') {
1036
+ const state = createClaudeSseState(fallbackModel);
1037
+ const stream = createDecodedStream(upstreamResponse);
1038
+ let buffer = '';
1039
+
1040
+ return new Promise((resolve, reject) => {
1041
+ stream.on('data', (chunk) => {
1042
+ buffer += chunk.toString('utf8');
1043
+ const blocks = buffer.split('\n\n');
1044
+ buffer = blocks.pop() || '';
1045
+
1046
+ blocks.forEach((block) => {
1047
+ const lines = block.split(/\r?\n/);
1048
+ const dataLines = lines
1049
+ .map((line) => line.trim())
1050
+ .filter((line) => line.startsWith('data:'))
1051
+ .map((line) => line.slice(5).trim())
1052
+ .filter(Boolean);
1053
+
1054
+ if (dataLines.length === 0) return;
1055
+ const data = dataLines.join('\n');
1056
+ if (data === '[DONE]') return;
1057
+ const parsed = safeJsonParse(data);
1058
+ processChatStreamChunk(parsed, state, res);
1059
+ });
1060
+ });
1061
+
1062
+ stream.on('end', () => {
1063
+ if (!state.finalized) {
1064
+ const parsed = safeJsonParse(buffer.trim());
1065
+ if (parsed?.choices) {
1066
+ state.completedMessage = buildClaudeMessageFromChat(parsed, state.model);
1067
+ }
1068
+
1069
+ if (state.completedMessage) {
1070
+ if (!state.started) {
1071
+ emitFullClaudeMessageAsSse(res, state, state.completedMessage);
1072
+ } else {
1073
+ finalizeClaudeSse(res, state, state.completedMessage);
1074
+ }
1075
+ } else if (state.started) {
1076
+ state.completedMessage = buildClaudeMessageFromStreamState(state);
1077
+ finalizeClaudeSse(res, state, state.completedMessage);
1078
+ }
1079
+ }
1080
+
1081
+ resolve(state.completedMessage);
1082
+ });
1083
+
1084
+ stream.on('error', reject);
1085
+ upstreamResponse.on('error', reject);
1086
+ });
1087
+ }
1088
+
1089
+ function publishClaudeGatewayUsage(metadata, message, calculateCost) {
1090
+ return publishUsageLog({
1091
+ source: 'claude',
1092
+ metadata,
1093
+ model: message?.model || '',
1094
+ tokens: buildPublishTokensFromClaudeUsage(message?.usage || {}),
1095
+ calculateCost,
1096
+ broadcastLog,
1097
+ recordRequest,
1098
+ recordSuccess,
1099
+ allowBroadcast: true
1100
+ });
1101
+ }
1102
+
1103
+ function reportGatewayFailure({ channel, metadata, res, statusCode, message, error, stage, onDone }) {
1104
+ recordFailure(channel.id, 'claude', error || new Error(message));
1105
+ publishFailureLog({
1106
+ source: 'claude',
1107
+ metadata,
1108
+ message,
1109
+ error,
1110
+ statusCode,
1111
+ stage,
1112
+ broadcastLog
1113
+ });
1114
+ sendAnthropicError(res, statusCode, message);
1115
+ if (typeof onDone === 'function') {
1116
+ onDone();
1117
+ }
1118
+ return true;
1119
+ }
1120
+
1121
+ async function handleClaudeOpenAiGatewayRequest({ req, res, channel, effectiveKey, calculateCost, onDone }) {
1122
+ const pathname = getRequestPathname(req?.url || '');
1123
+ if (!isClaudeMessagesPath(pathname)) {
1124
+ return false;
1125
+ }
1126
+
1127
+ if (req?.method !== 'POST' || !req?.body || typeof req.body !== 'object') {
1128
+ sendAnthropicError(res, 400, 'OpenAI gateway only supports JSON POST payload');
1129
+ if (typeof onDone === 'function') {
1130
+ onDone();
1131
+ }
1132
+ return true;
1133
+ }
1134
+
1135
+ const requestId = `claude-openai-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1136
+ const startTime = Date.now();
1137
+ const targetApi = resolveClaudeGatewayTargetApi(channel);
1138
+ const converted = convertClaudeToOpenCodePayload({
1139
+ payload: req.body,
1140
+ options: { targetApi }
1141
+ });
1142
+ const upstreamModel = converted?.requestBody?.model || req.body.model || '';
1143
+ const metadata = {
1144
+ id: requestId,
1145
+ channel: channel?.name,
1146
+ channelId: channel?.id,
1147
+ startTime,
1148
+ requestModel: upstreamModel
1149
+ };
1150
+ const wantsStream = req.body.stream === true;
1151
+ const targetUrl = buildOpenAiTargetUrl(channel?.baseUrl || '', converted.endpoint || '/v1/responses');
1152
+ const headers = buildOpenAiHeaders(effectiveKey, {
1153
+ targetApi: converted.targetApi,
1154
+ wantsStream
1155
+ });
1156
+ const agent = buildProxyAgent(channel?.proxyUrl || '');
1157
+
1158
+ try {
1159
+ if (wantsStream) {
1160
+ const upstream = await postJsonStream(
1161
+ targetUrl,
1162
+ headers,
1163
+ converted.requestBody,
1164
+ REQUEST_TIMEOUT_MS,
1165
+ agent
1166
+ );
1167
+
1168
+ if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
1169
+ const rawBody = await collectHttpResponseBody(upstream.response);
1170
+ const parsedError = safeJsonParse(rawBody);
1171
+ const upstreamMessage = parsedError?.error?.message || parsedError?.message || rawBody || `HTTP ${upstream.statusCode}`;
1172
+ return reportGatewayFailure({
1173
+ channel,
1174
+ metadata,
1175
+ res,
1176
+ statusCode: upstream.statusCode,
1177
+ message: String(upstreamMessage).slice(0, 1000),
1178
+ error: new Error(String(upstreamMessage).slice(0, 200)),
1179
+ stage: 'openai_gateway_upstream',
1180
+ onDone
1181
+ });
1182
+ }
1183
+
1184
+ const message = converted.targetApi === 'chat.completions'
1185
+ ? await relayChatStreamAsClaude(upstream.response, res, upstreamModel)
1186
+ : await relayResponsesStreamAsClaude(upstream.response, res, upstreamModel);
1187
+
1188
+ if (message) {
1189
+ publishClaudeGatewayUsage(metadata, message, calculateCost);
1190
+ }
1191
+ if (typeof onDone === 'function') {
1192
+ onDone();
1193
+ }
1194
+ return true;
1195
+ }
1196
+
1197
+ const upstream = await postJson(
1198
+ targetUrl,
1199
+ headers,
1200
+ converted.requestBody,
1201
+ REQUEST_TIMEOUT_MS,
1202
+ agent
1203
+ );
1204
+ const parsedBody = safeJsonParse(upstream.rawBody);
1205
+
1206
+ if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
1207
+ const upstreamMessage = parsedBody?.error?.message || parsedBody?.message || upstream.rawBody || `HTTP ${upstream.statusCode}`;
1208
+ return reportGatewayFailure({
1209
+ channel,
1210
+ metadata,
1211
+ res,
1212
+ statusCode: upstream.statusCode,
1213
+ message: String(upstreamMessage).slice(0, 1000),
1214
+ error: new Error(String(upstreamMessage).slice(0, 200)),
1215
+ stage: 'openai_gateway_upstream',
1216
+ onDone
1217
+ });
1218
+ }
1219
+
1220
+ if (!parsedBody || typeof parsedBody !== 'object') {
1221
+ return reportGatewayFailure({
1222
+ channel,
1223
+ metadata,
1224
+ res,
1225
+ statusCode: 502,
1226
+ message: 'Invalid OpenAI gateway response',
1227
+ error: new Error('Invalid OpenAI gateway response'),
1228
+ stage: 'openai_gateway_parse',
1229
+ onDone
1230
+ });
1231
+ }
1232
+
1233
+ const message = converted.targetApi === 'chat.completions'
1234
+ ? buildClaudeMessageFromChat(parsedBody, upstreamModel)
1235
+ : buildClaudeMessageFromResponses(parsedBody, upstreamModel);
1236
+
1237
+ res.json(message);
1238
+ publishClaudeGatewayUsage(metadata, message, calculateCost);
1239
+ if (typeof onDone === 'function') {
1240
+ onDone();
1241
+ }
1242
+ return true;
1243
+ } catch (error) {
1244
+ return reportGatewayFailure({
1245
+ channel,
1246
+ metadata,
1247
+ res,
1248
+ statusCode: 502,
1249
+ message: `OpenAI gateway network error: ${error.message}`,
1250
+ error,
1251
+ stage: 'openai_gateway_network',
1252
+ onDone
1253
+ });
1254
+ }
1255
+ }
1256
+
1257
+ module.exports = {
1258
+ handleClaudeOpenAiGatewayRequest
1259
+ };