@juspay/neurolink 9.65.2 → 9.66.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +362 -354
  3. package/dist/cli/commands/proxy.js +154 -5
  4. package/dist/lib/proxy/modelRouter.d.ts +5 -1
  5. package/dist/lib/proxy/modelRouter.js +8 -0
  6. package/dist/lib/proxy/openaiFormat.d.ts +137 -0
  7. package/dist/lib/proxy/openaiFormat.js +801 -0
  8. package/dist/lib/proxy/proxyTranslationEngine.d.ts +124 -0
  9. package/dist/lib/proxy/proxyTranslationEngine.js +679 -0
  10. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +6 -5
  11. package/dist/lib/server/routes/claudeProxyRoutes.js +22 -355
  12. package/dist/lib/server/routes/index.d.ts +1 -0
  13. package/dist/lib/server/routes/index.js +10 -2
  14. package/dist/lib/server/routes/openaiProxyRoutes.d.ts +30 -0
  15. package/dist/lib/server/routes/openaiProxyRoutes.js +337 -0
  16. package/dist/lib/types/proxy.d.ts +179 -0
  17. package/dist/lib/types/server.d.ts +3 -0
  18. package/dist/proxy/modelRouter.d.ts +5 -1
  19. package/dist/proxy/modelRouter.js +8 -0
  20. package/dist/proxy/openaiFormat.d.ts +137 -0
  21. package/dist/proxy/openaiFormat.js +800 -0
  22. package/dist/proxy/proxyTranslationEngine.d.ts +124 -0
  23. package/dist/proxy/proxyTranslationEngine.js +678 -0
  24. package/dist/server/routes/claudeProxyRoutes.d.ts +6 -5
  25. package/dist/server/routes/claudeProxyRoutes.js +22 -355
  26. package/dist/server/routes/index.d.ts +1 -0
  27. package/dist/server/routes/index.js +10 -2
  28. package/dist/server/routes/openaiProxyRoutes.d.ts +30 -0
  29. package/dist/server/routes/openaiProxyRoutes.js +336 -0
  30. package/dist/types/proxy.d.ts +179 -0
  31. package/dist/types/server.d.ts +3 -0
  32. package/package.json +1 -1
@@ -0,0 +1,800 @@
1
+ /**
2
+ * OpenAI Chat Completions API format conversion layer.
3
+ *
4
+ * Provides a request parser (OpenAI -> NeuroLink), a response serializer
5
+ * (NeuroLink -> OpenAI), a streaming SSE state machine, and an error
6
+ * envelope helper. Together they allow NeuroLink to act as a
7
+ * drop-in OpenAI API proxy.
8
+ *
9
+ * Reference: https://platform.openai.com/docs/api-reference/chat/create
10
+ */
11
+ import { jsonSchema, tool } from "../utils/tool.js";
12
+ import { randomBytes } from "crypto";
13
+ import { normalizeJsonSchemaObject } from "../utils/schemaConversion.js";
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+ /** Generate a unique chat completion ID in the OpenAI format. */
18
+ export function generateChatCompletionId() {
19
+ return `chatcmpl-${randomBytes(12).toString("base64url").slice(0, 24)}`;
20
+ }
21
+ /** Generate an OpenAI-format tool call ID (`call_` + random chars). */
22
+ export function generateOpenAIToolCallId() {
23
+ return `call_${randomBytes(12).toString("base64url").slice(0, 24)}`;
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Request parser: OpenAI -> NeuroLink internal format
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * Parse an incoming OpenAI Chat Completions request into an intermediate
30
+ * representation consumable by NeuroLink's generate/stream pipeline.
31
+ *
32
+ * Handles:
33
+ * - System prompt extraction from system messages
34
+ * - Message flattening (text + image parts)
35
+ * - Tool definition conversion
36
+ * - tool_choice mapping
37
+ * - top_p, temperature, max_tokens pass-through
38
+ */
39
+ export function parseOpenAIRequest(body) {
40
+ // --- system prompt ---
41
+ const systemParts = [];
42
+ for (const msg of body.messages) {
43
+ if (msg.role === "system") {
44
+ systemParts.push(msg.content);
45
+ }
46
+ }
47
+ const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
48
+ // --- messages ---
49
+ // Find the index of the last user message so we can distinguish the
50
+ // current turn from history. Images from historical messages are kept
51
+ // inline as text references; only images from the latest user message
52
+ // are extracted into the top-level `images` array.
53
+ const conversationMessages = [];
54
+ const images = [];
55
+ let lastUserPrompt = "";
56
+ let lastUserMsgIdx = -1;
57
+ for (let i = body.messages.length - 1; i >= 0; i--) {
58
+ if (body.messages[i].role === "user") {
59
+ lastUserMsgIdx = i;
60
+ break;
61
+ }
62
+ }
63
+ // NOTE: This loop intentionally does NOT use MessageBuilder because the proxy
64
+ // layer translates between OpenAI's wire format and NeuroLink's internal
65
+ // representation. MessageBuilder is for SDK-side message construction from
66
+ // user inputs (files, images, etc.).
67
+ for (let msgIdx = 0; msgIdx < body.messages.length; msgIdx++) {
68
+ const msg = body.messages[msgIdx];
69
+ const isLatestUserMsg = msgIdx === lastUserMsgIdx;
70
+ if (msg.role === "system") {
71
+ // System messages are already extracted above; skip them in the
72
+ // conversation history to avoid duplication.
73
+ continue;
74
+ }
75
+ if (msg.role === "user") {
76
+ if (typeof msg.content === "string") {
77
+ conversationMessages.push({ role: msg.role, content: msg.content });
78
+ lastUserPrompt = msg.content;
79
+ }
80
+ else if (Array.isArray(msg.content)) {
81
+ const textParts = [];
82
+ for (const part of msg.content) {
83
+ if (part.type === "text") {
84
+ textParts.push(part.text);
85
+ }
86
+ else if (part.type === "image_url") {
87
+ if (isLatestUserMsg) {
88
+ images.push(part.image_url.url);
89
+ }
90
+ else {
91
+ textParts.push("[image]");
92
+ }
93
+ }
94
+ }
95
+ const combined = textParts.join("\n");
96
+ conversationMessages.push({ role: msg.role, content: combined });
97
+ lastUserPrompt = combined;
98
+ }
99
+ }
100
+ else if (msg.role === "assistant") {
101
+ const textParts = [];
102
+ if (msg.content) {
103
+ textParts.push(msg.content);
104
+ }
105
+ if (msg.tool_calls) {
106
+ for (const tc of msg.tool_calls) {
107
+ textParts.push(`[tool_use:${tc.id}:${tc.function.name}] ${tc.function.arguments}`);
108
+ }
109
+ }
110
+ const combined = textParts.join("\n");
111
+ conversationMessages.push({ role: msg.role, content: combined });
112
+ }
113
+ else if (msg.role === "tool") {
114
+ conversationMessages.push({
115
+ role: "user",
116
+ content: `[tool_result:${msg.tool_call_id}] ${msg.content}`,
117
+ });
118
+ }
119
+ }
120
+ // --- tools ---
121
+ const tools = {};
122
+ if (body.tools) {
123
+ for (const t of body.tools) {
124
+ tools[t.function.name] = tool({
125
+ description: t.function.description ?? "",
126
+ inputSchema: jsonSchema(normalizeJsonSchemaObject(t.function.parameters ?? { type: "object" })),
127
+ });
128
+ }
129
+ }
130
+ // --- tool_choice ---
131
+ let toolChoice;
132
+ let toolChoiceName;
133
+ if (body.tool_choice) {
134
+ if (typeof body.tool_choice === "string") {
135
+ switch (body.tool_choice) {
136
+ case "auto":
137
+ toolChoice = "auto";
138
+ break;
139
+ case "required":
140
+ toolChoice = "required";
141
+ break;
142
+ case "none":
143
+ toolChoice = "none";
144
+ break;
145
+ }
146
+ }
147
+ else if (typeof body.tool_choice === "object" &&
148
+ body.tool_choice.type === "function") {
149
+ toolChoice = "required";
150
+ toolChoiceName = body.tool_choice.function.name;
151
+ }
152
+ }
153
+ // --- stop sequences ---
154
+ let stopSequences;
155
+ if (body.stop) {
156
+ stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop];
157
+ }
158
+ // --- response format ---
159
+ let responseFormat;
160
+ if (body.response_format) {
161
+ responseFormat = {
162
+ type: body.response_format.type,
163
+ jsonSchema: body.response_format.json_schema,
164
+ };
165
+ }
166
+ return {
167
+ model: body.model,
168
+ maxTokens: body.max_tokens ?? body.max_completion_tokens ?? 4096,
169
+ temperature: body.temperature,
170
+ topP: body.top_p,
171
+ systemPrompt,
172
+ stream: body.stream === true,
173
+ prompt: lastUserPrompt,
174
+ images,
175
+ conversationMessages,
176
+ tools,
177
+ toolChoice,
178
+ toolChoiceName,
179
+ stopSequences,
180
+ responseFormat,
181
+ };
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Response serializer: NeuroLink result -> OpenAI response
185
+ // ---------------------------------------------------------------------------
186
+ /**
187
+ * Map NeuroLink finish-reason strings to OpenAI finish_reason values.
188
+ */
189
+ function mapFinishReason(finishReason) {
190
+ switch (finishReason) {
191
+ case "stop":
192
+ case "end_turn":
193
+ return "stop";
194
+ case "length":
195
+ case "max_tokens":
196
+ return "length";
197
+ case "tool-calls":
198
+ case "tool_use":
199
+ return "tool_calls";
200
+ case "content_filter":
201
+ case "safety":
202
+ return "content_filter";
203
+ default:
204
+ return finishReason ? "stop" : null;
205
+ }
206
+ }
207
+ /**
208
+ * Serialize a NeuroLink GenerateResult into an OpenAI Chat Completions response.
209
+ */
210
+ export function serializeOpenAIResponse(result, requestModel) {
211
+ const inferredFinishReason = result.toolCalls &&
212
+ result.toolCalls.length > 0 &&
213
+ (!result.finishReason || result.finishReason === "stop")
214
+ ? "tool_calls"
215
+ : result.finishReason;
216
+ // Build tool_calls array if present
217
+ let toolCalls;
218
+ if (result.toolCalls && result.toolCalls.length > 0) {
219
+ toolCalls = result.toolCalls.map((tc) => ({
220
+ id: tc.toolCallId || generateOpenAIToolCallId(),
221
+ type: "function",
222
+ function: {
223
+ name: tc.toolName,
224
+ arguments: JSON.stringify(tc.args ?? {}),
225
+ },
226
+ }));
227
+ }
228
+ // Content is null when only tool calls are present (no text content)
229
+ const content = toolCalls && !result.content ? null : result.content || "";
230
+ return {
231
+ id: generateChatCompletionId(),
232
+ object: "chat.completion",
233
+ created: Math.floor(Date.now() / 1000),
234
+ model: result.model ?? requestModel,
235
+ choices: [
236
+ {
237
+ index: 0,
238
+ message: {
239
+ role: "assistant",
240
+ content,
241
+ ...(toolCalls ? { tool_calls: toolCalls } : {}),
242
+ },
243
+ finish_reason: mapFinishReason(inferredFinishReason),
244
+ },
245
+ ],
246
+ usage: {
247
+ prompt_tokens: result.usage?.input ?? 0,
248
+ completion_tokens: result.usage?.output ?? 0,
249
+ total_tokens: result.usage?.total ?? 0,
250
+ },
251
+ };
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // Error envelope
255
+ // ---------------------------------------------------------------------------
256
+ /** Map HTTP status codes to OpenAI error types. */
257
+ function errorTypeFromStatus(status) {
258
+ switch (status) {
259
+ case 400:
260
+ return "invalid_request_error";
261
+ case 401:
262
+ return "authentication_error";
263
+ case 403:
264
+ return "permission_error";
265
+ case 404:
266
+ return "not_found_error";
267
+ case 429:
268
+ return "rate_limit_error";
269
+ default:
270
+ return status >= 500 ? "server_error" : "invalid_request_error";
271
+ }
272
+ }
273
+ /**
274
+ * Build an OpenAI-compatible error envelope.
275
+ */
276
+ export function buildOpenAIError(status, message) {
277
+ return {
278
+ error: {
279
+ message,
280
+ type: errorTypeFromStatus(status),
281
+ code: null,
282
+ },
283
+ };
284
+ }
285
+ // ---------------------------------------------------------------------------
286
+ // SSE helpers
287
+ // ---------------------------------------------------------------------------
288
+ /**
289
+ * Format a single OpenAI SSE frame.
290
+ * OpenAI uses only `data:` lines (no `event:` prefix unlike Claude).
291
+ */
292
+ export function formatOpenAISSE(data) {
293
+ return `data: ${JSON.stringify(data)}\n\n`;
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Streaming SSE state machine
297
+ // ---------------------------------------------------------------------------
298
+ /**
299
+ * Stateful SSE serializer that emits a well-formed OpenAI streaming response.
300
+ *
301
+ * Tracks lifecycle state (`idle` -> `streaming` -> `done`) and the current
302
+ * tool call index for multi-tool streaming.
303
+ *
304
+ * Usage:
305
+ * ```ts
306
+ * const sse = new OpenAIStreamSerializer(requestModel);
307
+ *
308
+ * // Start the stream
309
+ * yield* sse.start();
310
+ *
311
+ * // Text deltas
312
+ * for await (const chunk of textStream) {
313
+ * yield* sse.pushDelta(chunk);
314
+ * }
315
+ *
316
+ * // Tool use
317
+ * yield* sse.pushToolUse(toolId, toolName, toolInput);
318
+ *
319
+ * // Finalize
320
+ * yield* sse.finish("stop", usage);
321
+ * ```
322
+ */
323
+ export class OpenAIStreamSerializer {
324
+ state = "idle";
325
+ id;
326
+ model;
327
+ started = false;
328
+ toolCallIndex = -1;
329
+ constructor(model) {
330
+ this.id = generateChatCompletionId();
331
+ this.model = model;
332
+ }
333
+ /** Current lifecycle state (exposed for testing). */
334
+ getState() {
335
+ return this.state;
336
+ }
337
+ // -----------------------------------------------------------------------
338
+ // Internal helpers
339
+ // -----------------------------------------------------------------------
340
+ makeChunk(delta, finishReason = null, usage) {
341
+ return {
342
+ id: this.id,
343
+ object: "chat.completion.chunk",
344
+ created: Math.floor(Date.now() / 1000),
345
+ model: this.model,
346
+ choices: [
347
+ {
348
+ index: 0,
349
+ delta,
350
+ finish_reason: finishReason,
351
+ },
352
+ ],
353
+ ...(usage ? { usage } : {}),
354
+ };
355
+ }
356
+ // -----------------------------------------------------------------------
357
+ // Public API
358
+ // -----------------------------------------------------------------------
359
+ /**
360
+ * Emit the opening frame with `role: "assistant"`.
361
+ */
362
+ *start() {
363
+ if (this.state !== "idle") {
364
+ return;
365
+ }
366
+ this.started = true;
367
+ this.state = "streaming";
368
+ yield formatOpenAISSE(this.makeChunk({ role: "assistant" }));
369
+ }
370
+ /**
371
+ * Push a text content delta.
372
+ */
373
+ *pushDelta(text) {
374
+ if (this.state === "done" || this.state === "error") {
375
+ return;
376
+ }
377
+ if (!this.started) {
378
+ yield* this.start();
379
+ }
380
+ yield formatOpenAISSE(this.makeChunk({ content: text }));
381
+ }
382
+ /**
383
+ * Push the start of a tool call (id, name, empty arguments).
384
+ */
385
+ *pushToolCallStart(id, name) {
386
+ if (this.state === "done" || this.state === "error") {
387
+ return;
388
+ }
389
+ if (!this.started) {
390
+ yield* this.start();
391
+ }
392
+ this.toolCallIndex += 1;
393
+ yield formatOpenAISSE(this.makeChunk({
394
+ tool_calls: [
395
+ {
396
+ index: this.toolCallIndex,
397
+ id,
398
+ type: "function",
399
+ function: { name, arguments: "" },
400
+ },
401
+ ],
402
+ }));
403
+ }
404
+ /**
405
+ * Push an arguments delta for the current tool call.
406
+ */
407
+ *pushToolCallArgDelta(index, argsChunk) {
408
+ if (this.state === "done" || this.state === "error") {
409
+ return;
410
+ }
411
+ yield formatOpenAISSE(this.makeChunk({
412
+ tool_calls: [
413
+ {
414
+ index,
415
+ function: { arguments: argsChunk },
416
+ },
417
+ ],
418
+ }));
419
+ }
420
+ /**
421
+ * Push a complete tool use: emits tool call start followed by chunked
422
+ * argument deltas (~100 chars per chunk).
423
+ */
424
+ *pushToolUse(id, name, input) {
425
+ if (this.state === "done" || this.state === "error") {
426
+ return;
427
+ }
428
+ yield* this.pushToolCallStart(id, name);
429
+ const jsonStr = JSON.stringify(input ?? {});
430
+ const CHUNK_SIZE = 100;
431
+ const currentIndex = this.toolCallIndex;
432
+ for (let i = 0; i < jsonStr.length; i += CHUNK_SIZE) {
433
+ const chunk = jsonStr.slice(i, i + CHUNK_SIZE);
434
+ yield* this.pushToolCallArgDelta(currentIndex, chunk);
435
+ }
436
+ // If the input was empty, still emit at least one delta
437
+ if (jsonStr.length === 0) {
438
+ yield* this.pushToolCallArgDelta(currentIndex, "{}");
439
+ }
440
+ }
441
+ /**
442
+ * Finalize the stream: emit finish_reason chunk, then `data: [DONE]`.
443
+ */
444
+ *finish(finishReason, usage) {
445
+ if (this.state === "idle") {
446
+ yield* this.start();
447
+ }
448
+ if (this.state === "done" || this.state === "error") {
449
+ return;
450
+ }
451
+ const mappedReason = mapFinishReason(finishReason) ?? "stop";
452
+ const usagePayload = usage
453
+ ? {
454
+ prompt_tokens: usage.input,
455
+ completion_tokens: usage.output,
456
+ total_tokens: usage.total,
457
+ }
458
+ : undefined;
459
+ yield formatOpenAISSE(this.makeChunk({}, mappedReason, usagePayload));
460
+ yield "data: [DONE]\n\n";
461
+ this.state = "done";
462
+ }
463
+ /**
464
+ * Emit an error event. Transitions to terminal ERROR state.
465
+ */
466
+ *emitError(message) {
467
+ this.state = "error";
468
+ yield formatOpenAISSE({
469
+ error: { message, type: "server_error" },
470
+ });
471
+ }
472
+ }
473
+ // ---------------------------------------------------------------------------
474
+ // OpenAI <-> Claude (Anthropic) format bridge
475
+ // ---------------------------------------------------------------------------
476
+ /**
477
+ * Convert an OpenAI Chat Completions request to a Claude Messages API request.
478
+ *
479
+ * Used by the OpenAI proxy endpoint to internally loopback requests targeting
480
+ * Claude models through the proxy's native /v1/messages passthrough path,
481
+ * so they benefit from OAuth account rotation, retry, SSE interception, etc.
482
+ */
483
+ export function convertOpenAIToClaudeRequest(openai) {
484
+ // --- system messages ---
485
+ const systemMessages = openai.messages.filter((m) => m.role === "system");
486
+ const system = systemMessages.length > 0
487
+ ? systemMessages.map((m) => ({ type: "text", text: m.content }))
488
+ : undefined;
489
+ // --- conversation messages ---
490
+ const messages = [];
491
+ for (const msg of openai.messages) {
492
+ if (msg.role === "system") {
493
+ continue;
494
+ }
495
+ if (msg.role === "user") {
496
+ if (typeof msg.content === "string") {
497
+ messages.push({ role: "user", content: msg.content });
498
+ }
499
+ else if (Array.isArray(msg.content)) {
500
+ const blocks = msg.content.map((part) => {
501
+ if (part.type === "text") {
502
+ return { type: "text", text: part.text };
503
+ }
504
+ if (part.type === "image_url") {
505
+ return {
506
+ type: "image",
507
+ source: { type: "url", url: part.image_url.url },
508
+ };
509
+ }
510
+ return { type: "text", text: "" };
511
+ });
512
+ messages.push({ role: "user", content: blocks });
513
+ }
514
+ }
515
+ else if (msg.role === "assistant") {
516
+ const blocks = [];
517
+ if (msg.content) {
518
+ blocks.push({ type: "text", text: msg.content });
519
+ }
520
+ if (msg.tool_calls) {
521
+ for (const tc of msg.tool_calls) {
522
+ let input;
523
+ try {
524
+ input = tc.function.arguments
525
+ ? JSON.parse(tc.function.arguments)
526
+ : {};
527
+ }
528
+ catch {
529
+ input = {};
530
+ }
531
+ blocks.push({
532
+ type: "tool_use",
533
+ id: tc.id,
534
+ name: tc.function.name,
535
+ input,
536
+ });
537
+ }
538
+ }
539
+ messages.push({
540
+ role: "assistant",
541
+ content: blocks.length === 1 && blocks[0].type === "text"
542
+ ? blocks[0].text
543
+ : blocks,
544
+ });
545
+ }
546
+ else if (msg.role === "tool") {
547
+ messages.push({
548
+ role: "user",
549
+ content: [
550
+ {
551
+ type: "tool_result",
552
+ tool_use_id: msg.tool_call_id,
553
+ content: msg.content,
554
+ },
555
+ ],
556
+ });
557
+ }
558
+ }
559
+ // --- tools ---
560
+ const tools = openai.tools?.map((t) => ({
561
+ name: t.function.name,
562
+ description: t.function.description || "",
563
+ input_schema: t.function.parameters,
564
+ }));
565
+ // --- tool_choice ---
566
+ let tool_choice;
567
+ if (openai.tool_choice === "auto") {
568
+ tool_choice = { type: "auto" };
569
+ }
570
+ else if (openai.tool_choice === "required") {
571
+ tool_choice = { type: "any" };
572
+ }
573
+ else if (openai.tool_choice === "none") {
574
+ tool_choice = { type: "none" };
575
+ }
576
+ else if (typeof openai.tool_choice === "object" &&
577
+ openai.tool_choice.type === "function") {
578
+ tool_choice = { type: "tool", name: openai.tool_choice.function.name };
579
+ }
580
+ const result = {
581
+ model: openai.model,
582
+ messages,
583
+ max_tokens: openai.max_tokens ?? openai.max_completion_tokens ?? 4096,
584
+ stream: openai.stream ?? false,
585
+ };
586
+ if (system) {
587
+ result.system = system;
588
+ }
589
+ if (openai.temperature !== undefined) {
590
+ result.temperature = openai.temperature;
591
+ }
592
+ if (openai.top_p !== undefined) {
593
+ result.top_p = openai.top_p;
594
+ }
595
+ if (tools && tools.length > 0) {
596
+ result.tools = tools;
597
+ }
598
+ if (tool_choice) {
599
+ result.tool_choice = tool_choice;
600
+ }
601
+ if (openai.stop) {
602
+ result.stop_sequences = Array.isArray(openai.stop)
603
+ ? openai.stop
604
+ : [openai.stop];
605
+ }
606
+ return result;
607
+ }
608
+ /**
609
+ * Convert a non-streaming Claude Messages response to an OpenAI Chat
610
+ * Completions response by bridging through {@link InternalResult}.
611
+ */
612
+ export function convertClaudeToOpenAIResponse(claude, requestModel) {
613
+ const content = claude.content
614
+ .filter((b) => b.type === "text")
615
+ .map((b) => b.text)
616
+ .join("");
617
+ const toolCalls = claude.content
618
+ .filter((b) => b.type === "tool_use")
619
+ .map((b) => {
620
+ const tu = b;
621
+ return {
622
+ toolCallId: tu.id,
623
+ toolName: tu.name,
624
+ args: tu.input ?? {},
625
+ };
626
+ });
627
+ const internal = {
628
+ content,
629
+ model: claude.model,
630
+ finishReason: claude.stop_reason ?? "end_turn",
631
+ usage: {
632
+ input: claude.usage.input_tokens,
633
+ output: claude.usage.output_tokens,
634
+ total: claude.usage.input_tokens + claude.usage.output_tokens,
635
+ },
636
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
637
+ };
638
+ return serializeOpenAIResponse(internal, requestModel);
639
+ }
640
+ /**
641
+ * Create a TransformStream that parses Claude Messages API SSE events from
642
+ * the upstream response and re-emits them as OpenAI Chat Completions SSE
643
+ * frames.
644
+ *
645
+ * Handles the canonical Claude SSE event types:
646
+ * - message_start -> emits the opening `role: "assistant"` chunk
647
+ * - content_block_start -> text block: no-op; tool_use block: emit tool call start
648
+ * - content_block_delta -> text_delta: emit content delta;
649
+ * input_json_delta: emit tool call argument delta
650
+ * - content_block_stop -> no-op
651
+ * - message_delta -> captures stop_reason and output token usage
652
+ * - message_stop -> emits the final `finish_reason` chunk + `[DONE]`
653
+ */
654
+ export function createClaudeToOpenAIStreamTransform(requestModel) {
655
+ const serializer = new OpenAIStreamSerializer(requestModel);
656
+ const encoder = new TextEncoder();
657
+ const decoder = new TextDecoder();
658
+ let buffer = "";
659
+ // Track per-content-block state so we can map Claude's indexed block
660
+ // stream to OpenAI's flat delta stream.
661
+ const blockState = new Map();
662
+ let stopReason;
663
+ let usage;
664
+ let inputTokens = 0;
665
+ let nextToolCallIndex = 0;
666
+ let finished = false;
667
+ const emit = (controller, gen) => {
668
+ for (const frame of gen) {
669
+ controller.enqueue(encoder.encode(frame));
670
+ }
671
+ };
672
+ const handleEvent = (eventName, dataStr, controller) => {
673
+ let data;
674
+ try {
675
+ data = JSON.parse(dataStr);
676
+ }
677
+ catch {
678
+ return;
679
+ }
680
+ switch (eventName) {
681
+ case "message_start": {
682
+ const message = (data.message ?? {});
683
+ if (message.usage?.input_tokens !== undefined) {
684
+ inputTokens = message.usage.input_tokens;
685
+ }
686
+ emit(controller, serializer.start());
687
+ return;
688
+ }
689
+ case "content_block_start": {
690
+ const index = typeof data.index === "number" ? data.index : 0;
691
+ const block = (data.content_block ?? {});
692
+ if (block.type === "tool_use") {
693
+ const toolCallIndex = nextToolCallIndex++;
694
+ blockState.set(index, { kind: "tool_use", toolCallIndex });
695
+ emit(controller, serializer.pushToolCallStart(block.id ?? "", block.name ?? ""));
696
+ }
697
+ else if (block.type === "text") {
698
+ blockState.set(index, { kind: "text" });
699
+ }
700
+ else if (block.type === "thinking") {
701
+ blockState.set(index, { kind: "thinking" });
702
+ }
703
+ else {
704
+ blockState.set(index, { kind: "other" });
705
+ }
706
+ return;
707
+ }
708
+ case "content_block_delta": {
709
+ const index = typeof data.index === "number" ? data.index : 0;
710
+ const delta = (data.delta ?? {});
711
+ const state = blockState.get(index);
712
+ if (!state) {
713
+ return;
714
+ }
715
+ if (delta.type === "text_delta" && state.kind === "text") {
716
+ emit(controller, serializer.pushDelta(delta.text ?? ""));
717
+ }
718
+ else if (delta.type === "input_json_delta" &&
719
+ state.kind === "tool_use" &&
720
+ state.toolCallIndex !== undefined) {
721
+ emit(controller, serializer.pushToolCallArgDelta(state.toolCallIndex, delta.partial_json ?? ""));
722
+ }
723
+ // thinking_delta is intentionally dropped — OpenAI has no equivalent.
724
+ return;
725
+ }
726
+ case "content_block_stop": {
727
+ // No-op: OpenAI stream has no per-block close event.
728
+ return;
729
+ }
730
+ case "message_delta": {
731
+ const delta = (data.delta ?? {});
732
+ if (delta.stop_reason) {
733
+ stopReason = delta.stop_reason;
734
+ }
735
+ const u = (data.usage ?? {});
736
+ if (u.output_tokens !== undefined) {
737
+ usage = {
738
+ input: inputTokens,
739
+ output: u.output_tokens,
740
+ total: inputTokens + u.output_tokens,
741
+ };
742
+ }
743
+ return;
744
+ }
745
+ case "message_stop": {
746
+ if (!finished) {
747
+ finished = true;
748
+ emit(controller, serializer.finish(stopReason, usage));
749
+ }
750
+ return;
751
+ }
752
+ default:
753
+ // ping, error, and unknown events are ignored.
754
+ return;
755
+ }
756
+ };
757
+ // Parse any complete SSE events present in `buffer`, mutating it as events
758
+ // are consumed. Shared between the streaming `transform` and the terminal
759
+ // `flush` (after the decoder is drained) so trailing events aren't lost.
760
+ const drainBufferedEvents = (controller) => {
761
+ // Claude SSE events are separated by blank lines (`\n\n`).
762
+ let sepIdx = buffer.indexOf("\n\n");
763
+ while (sepIdx !== -1) {
764
+ const rawEvent = buffer.slice(0, sepIdx);
765
+ buffer = buffer.slice(sepIdx + 2);
766
+ let eventName = "";
767
+ const dataLines = [];
768
+ for (const line of rawEvent.split("\n")) {
769
+ if (line.startsWith("event:")) {
770
+ eventName = line.slice(6).trim();
771
+ }
772
+ else if (line.startsWith("data:")) {
773
+ dataLines.push(line.slice(5).trim());
774
+ }
775
+ }
776
+ if (eventName && dataLines.length > 0) {
777
+ handleEvent(eventName, dataLines.join("\n"), controller);
778
+ }
779
+ sepIdx = buffer.indexOf("\n\n");
780
+ }
781
+ };
782
+ return new TransformStream({
783
+ transform(chunk, controller) {
784
+ buffer += decoder.decode(chunk, { stream: true });
785
+ drainBufferedEvents(controller);
786
+ },
787
+ flush(controller) {
788
+ // Drain any bytes still held inside the TextDecoder, then re-run the
789
+ // event parser so a complete trailing event that arrived without a
790
+ // closing `\n\n` is not silently lost.
791
+ buffer += decoder.decode();
792
+ drainBufferedEvents(controller);
793
+ // If the upstream closed without a message_stop, still finalize.
794
+ if (!finished) {
795
+ finished = true;
796
+ emit(controller, serializer.finish(stopReason, usage));
797
+ }
798
+ },
799
+ });
800
+ }