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.
- package/CHANGELOG.md +15 -0
- package/dist/web/assets/{Analytics-B9Shk-uq.js → Analytics-DvWZOZ13.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-Dlkn-_zW.js → ConfigTemplates-DiuKu3LA.js} +1 -1
- package/dist/web/assets/{Home-AGf32LvJ.js → Home-DeGTc9uf.js} +1 -1
- package/dist/web/assets/{PluginManager-BnAjHN4u.js → PluginManager-CljkN0Rb.js} +1 -1
- package/dist/web/assets/{ProjectList-7YV_zk9L.js → ProjectList-CFQJaSQ8.js} +1 -1
- package/dist/web/assets/{SessionList-D0un-UKS.js → SessionList-Fq4uQSQp.js} +1 -1
- package/dist/web/assets/{SkillManager-DhYBmbtd.js → SkillManager-DLhg11LF.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-JOZIPAGo.js → WorkspaceManager-DXB4kE9p.js} +1 -1
- package/dist/web/assets/index-CLra7uzu.js +2 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/commands/daemon.js +306 -98
- package/src/config/channel-preset-websites.js +1 -0
- package/src/index.js +18 -11
- package/src/server/api/channels.js +62 -17
- package/src/server/api/codex-channels.js +12 -1
- package/src/server/api/pm2-autostart.js +59 -24
- package/src/server/proxy-server.js +97 -5
- package/src/server/services/channels.js +62 -4
- package/src/server/services/claude-openai-gateway.js +1259 -0
- package/src/server/services/claude-token-recovery.js +386 -0
- package/src/server/services/codex-channels.js +8 -7
- package/src/server/services/proxy-log-helper.js +9 -4
- package/dist/web/assets/index-DM3yF1Oo.js +0 -2
|
@@ -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
|
+
};
|