diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 440d446a79..53ea1480d7 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -130,7 +130,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const getToolCallKey = (toolCall: Record) => { if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`; - if (toolCall?.id) return `id:${toolCall.id}`; + if (toolCall?.id != null) return `id:${String(toolCall.id)}`; unknownToolCallSeq += 1; return `seq:${unknownToolCallSeq}`; }; @@ -448,11 +448,16 @@ function toOutputIndex(value) { return null; } +function toIdString(value) { + return value === null || value === undefined ? "" : String(value); +} + function cloneResponseItem(item) { const record = toRecord(item); return { ...record, id: record.id != null ? String(record.id) : record.id, + call_id: record.call_id != null ? String(record.call_id) : record.call_id, ...(Array.isArray(record.content) ? { content: record.content.map((contentPart) => { @@ -517,18 +522,28 @@ function ensureResponsesReasoningItem(outputItems, outputIndex, itemId) { function ensureResponsesFunctionCallItem(outputItems, outputIndex, itemId, callId, name) { const existing = outputItems.get(outputIndex); + const normalizedItemId = toIdString(itemId); + const normalizedCallId = toIdString(callId); + const existingId = existing?.id != null ? String(existing.id) : ""; + const existingCallId = existing?.call_id != null ? String(existing.call_id) : ""; + if (existing?.type === "function_call") { - if (callId && !existing.call_id) existing.call_id = callId; + if (existing.call_id != null) existing.call_id = String(existing.call_id); + if (existing.id != null) existing.id = String(existing.id); + if (normalizedCallId && !existing.call_id) existing.call_id = normalizedCallId; if (name && !existing.name) existing.name = name; - if (itemId && !existing.id) existing.id = itemId; + if (normalizedItemId && !existing.id) existing.id = normalizedItemId; return existing; } const next = { ...(existing && typeof existing === "object" ? existing : {}), - id: itemId || (existing?.id != null ? String(existing.id) : null) || `fc_${callId || `${Date.now()}_${outputIndex}`}`, + id: + normalizedItemId || + existingId || + `fc_${normalizedCallId || `${Date.now()}_${outputIndex}`}`, type: "function_call", - call_id: callId || existing?.call_id || "", + call_id: normalizedCallId || existingCallId || "", name: name || existing?.name || "", arguments: typeof existing?.arguments === "string" ? existing.arguments : "", }; @@ -626,7 +641,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const reasoningItem = ensureResponsesReasoningItem( outputItems, outputIndex, - toString(evt.item_id) + toIdString(evt.item_id) ); const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; const firstPart = @@ -641,7 +656,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const reasoningItem = ensureResponsesReasoningItem( outputItems, outputIndex, - toString(evt.item_id) + toIdString(evt.item_id) ); const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; const firstPart = @@ -656,7 +671,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const functionCallItem = ensureResponsesFunctionCallItem( outputItems, outputIndex, - toString(evt.item_id), + toIdString(evt.item_id), "", "" ); @@ -667,7 +682,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { const functionCallItem = ensureResponsesFunctionCallItem( outputItems, outputIndex, - toString(evt.item_id), + toIdString(evt.item_id), "", "" ); @@ -709,10 +724,14 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { id: picked.id != null ? String(picked.id) : `resp_${Date.now()}`, object: picked.object || "response", model: picked.model || fallbackModel || "unknown", - output: (pickedOutput.length > 0 ? pickedOutput : reconstructedOutput).map((item) => ({ - ...item, - id: item.id != null ? String(item.id) : item.id, - })), + output: (pickedOutput.length > 0 ? pickedOutput : reconstructedOutput).map((item) => { + const record = toRecord(item); + return { + ...record, + id: record.id != null ? String(record.id) : record.id, + call_id: record.call_id != null ? String(record.call_id) : record.call_id, + }; + }), usage: picked.usage || null, status: picked.status || statusFallback, created_at: picked.created_at || Math.floor(Date.now() / 1000), diff --git a/open-sse/utils/ollamaTransform.ts b/open-sse/utils/ollamaTransform.ts index 2bb51eda1a..b87b39bf63 100644 --- a/open-sse/utils/ollamaTransform.ts +++ b/open-sse/utils/ollamaTransform.ts @@ -44,14 +44,19 @@ export function transformToOllama(response, model) { for (const tc of toolCalls) { const idx = tc.index; + const toolCallId = tc.id != null ? String(tc.id) : tc.id; + // T37: Prevent merging tool_calls on same index if ID changes - if (pendingToolCalls[idx] && tc.id && pendingToolCalls[idx].id !== tc.id) { + if (pendingToolCalls[idx] && toolCallId && pendingToolCalls[idx].id !== toolCallId) { completedToolCalls.push(pendingToolCalls[idx]); delete pendingToolCalls[idx]; } if (!pendingToolCalls[idx]) { - pendingToolCalls[idx] = { id: tc.id != null ? String(tc.id) : tc.id, function: { name: "", arguments: "" } }; + pendingToolCalls[idx] = { + id: toolCallId, + function: { name: "", arguments: "" }, + }; } if (tc.function?.name) pendingToolCalls[idx].function.name += tc.function.name; if (tc.function?.arguments) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index c8329f6cb1..8cdf4fa2fc 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -62,6 +62,81 @@ export { COLORS, formatSSE }; type JsonRecord = Record; +function stringifyIdValue(value: unknown): string | null { + return value === null || value === undefined ? null : String(value); +} + +function normalizeResponsesOutputItemIds(item: unknown): unknown { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return item; + } + + const record = item as JsonRecord; + let changed = false; + const normalized = { ...record }; + + const id = stringifyIdValue(record.id); + if (id !== null && record.id !== id) { + normalized.id = id; + changed = true; + } + + const callId = stringifyIdValue(record.call_id); + if (callId !== null && record.call_id !== callId) { + normalized.call_id = callId; + changed = true; + } + + return changed ? normalized : item; +} + +function normalizeResponsesSseIds(payload: JsonRecord): boolean { + let changed = false; + + for (const key of ["response_id", "item_id", "call_id"] as const) { + const value = stringifyIdValue(payload[key]); + if (value !== null && payload[key] !== value) { + payload[key] = value; + changed = true; + } + } + + if (payload.item && typeof payload.item === "object" && !Array.isArray(payload.item)) { + const normalizedItem = normalizeResponsesOutputItemIds(payload.item); + if (normalizedItem !== payload.item) { + payload.item = normalizedItem; + changed = true; + } + } + + if (payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) { + const response = payload.response as JsonRecord; + let responseChanged = false; + const normalizedResponse = { ...response }; + + const responseId = stringifyIdValue(response.id); + if (responseId !== null && response.id !== responseId) { + normalizedResponse.id = responseId; + responseChanged = true; + } + + if (Array.isArray(response.output)) { + const normalizedOutput = response.output.map(normalizeResponsesOutputItemIds); + if (normalizedOutput.some((item, index) => item !== response.output[index])) { + normalizedResponse.output = normalizedOutput; + responseChanged = true; + } + } + + if (responseChanged) { + payload.response = normalizedResponse; + changed = true; + } + } + + return changed; +} + export const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared"; function markPendingRequestCleared(error: Error): Error { @@ -76,8 +151,8 @@ function buildResponsesOutputItemKey(item: unknown): string | null { const record = item as JsonRecord; const type = typeof record.type === "string" ? record.type : ""; - const id = typeof record.id === "string" ? record.id : ""; - const callId = typeof record.call_id === "string" ? record.call_id : ""; + const id = stringifyIdValue(record.id) ?? ""; + const callId = stringifyIdValue(record.call_id) ?? ""; const outputIndex = typeof record.output_index === "number" ? record.output_index : ""; const name = typeof record.name === "string" ? record.name : ""; @@ -1025,22 +1100,21 @@ export function createSSEStream(options: StreamOptions = {}) { }; const getResponsesReasoningKey = (payload: Record): string | null => { - if (typeof payload.item_id === "string" && payload.item_id) { - return payload.item_id; + const itemId = stringifyIdValue(payload.item_id); + if (itemId) { + return itemId; } const item = payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) ? (payload.item as Record) : null; - if (item && typeof item.id === "string" && item.id) { - return item.id; + const outputItemId = item ? stringifyIdValue(item.id) : null; + if (outputItemId) { + return outputItemId; } - const responseId = - typeof payload.response_id === "string" && payload.response_id - ? payload.response_id - : passthroughResponsesId; + const responseId = stringifyIdValue(payload.response_id) || passthroughResponsesId; const outputIndex = typeof payload.output_index === "number" && Number.isInteger(payload.output_index) ? payload.output_index @@ -1319,12 +1393,16 @@ export function createSSEStream(options: StreamOptions = {}) { parsed.type === "error"); if (isResponsesSSE) { + const responsesIdsNormalized = normalizeResponsesSseIds(parsed as JsonRecord); + const parsedResponse = + parsed.response && + typeof parsed.response === "object" && + !Array.isArray(parsed.response) + ? (parsed.response as JsonRecord) + : null; const responseId = - typeof parsed.response?.id === "string" - ? parsed.response.id - : typeof parsed.response_id === "string" - ? parsed.response_id - : null; + (parsedResponse ? stringifyIdValue(parsedResponse.id) : null) || + stringifyIdValue(parsed.response_id); if (responseId) { passthroughResponsesId = responseId; } @@ -1530,7 +1608,7 @@ export function createSSEStream(options: StreamOptions = {}) { parsed, passthroughResponsesOutputItems ); - if (stripped || backfilled || textualToolCallBackfilled) { + if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } @@ -1655,6 +1733,7 @@ export function createSSEStream(options: StreamOptions = {}) { ) ) : false; + const hadNonStringTopLevelId = parsed?.id != null && typeof parsed.id !== "string"; parsed = sanitizeStreamingChunk(parsed); if ( @@ -1666,7 +1745,7 @@ export function createSSEStream(options: StreamOptions = {}) { continue; } - const idFixed = fixInvalidId(parsed); + const idFixed = hadNonStringTopLevelId ? false : fixInvalidId(parsed); if (!hasValuableContent(parsed, FORMATS.OPENAI)) { continue; @@ -1718,18 +1797,18 @@ export function createSSEStream(options: StreamOptions = {}) { passthroughHasToolCalls = true; lastToolCallChunkTime = Date.now(); for (const tc of delta.tool_calls) { - if (tc?.id != null) { - const stringId = String(tc.id); - if (tc.id !== stringId) { - tc.id = stringId; - toolCallIdCoerced = true; - } + // Note: sanitizeStreamingChunk above already coerces non-string + // tool_call IDs, but this defensive check catches edge cases + // where sanitize didn't run (e.g. flush path shortcuts). + if (tc?.id != null && typeof tc.id !== "string") { + tc.id = String(tc.id); + toolCallIdCoerced = true; } // Key by index first — id only appears on the first delta in OpenAI streaming let key: string; if (Number.isInteger(tc?.index)) { key = `idx:${tc.index}`; - } else if (tc?.id) { + } else if (tc?.id != null) { key = `id:${tc.id}`; } else { key = `seq:${++passthroughToolCallSeq}`; @@ -1839,7 +1918,8 @@ export function createSSEStream(options: StreamOptions = {}) { idFixed || needsReserialization || toolCallIdCoerced || - hadNonStringToolCallId + hadNonStringToolCallId || + hadNonStringTopLevelId ) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; @@ -2098,6 +2178,43 @@ export function createSSEStream(options: StreamOptions = {}) { updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload); } clientPayloadCollector.push(bufferedPayload); + + // Normalize numeric IDs for final buffered data: chunk (same as transform path) + if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) { + const flushedParsed = bufferedPayload as JsonRecord; + const flushedType = typeof flushedParsed.type === "string" ? flushedParsed.type : ""; + const isResponses = flushedType.startsWith("response."); + const isClaude = isClaudeEventPayload(flushedParsed); + if (isResponses) { + if (normalizeResponsesSseIds(flushedParsed)) { + output = `data: ${JSON.stringify(flushedParsed)}\n`; + } + } else if (!isClaude) { + let flushChanged = false; + const flushedHadNonStringTopLevelId = + flushedParsed?.id != null && typeof flushedParsed.id !== "string"; + if (flushedHadNonStringTopLevelId) { + flushedParsed.id = String(flushedParsed.id); + flushChanged = true; + } + if (Array.isArray(flushedParsed.choices)) { + for (const choice of flushedParsed.choices as JsonRecord[]) { + const tcs = (choice as JsonRecord | undefined)?.delta as JsonRecord | undefined; + if (Array.isArray(tcs?.tool_calls)) { + for (const tc of tcs.tool_calls as JsonRecord[]) { + if (tc?.id != null && typeof tc.id !== "string") { + tc.id = String(tc.id); + flushChanged = true; + } + } + } + } + } + if (flushChanged) { + output = `data: ${JSON.stringify(flushedParsed)}\n`; + } + } + } } if (!bufferedLine && pendingPassthroughEventLine && !pendingPassthroughEventEmitted) { output = `${pendingPassthroughEventLine}\n${output}`; diff --git a/tests/unit/ollama-transform.test.ts b/tests/unit/ollama-transform.test.ts index c747e5984d..279d2973fe 100644 --- a/tests/unit/ollama-transform.test.ts +++ b/tests/unit/ollama-transform.test.ts @@ -55,18 +55,22 @@ test("transformToOllama handles string tool_call id normally", async () => { object: "chat.completion.chunk", created: 1, model: "gpt-4", - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: "call_abc", - type: "function", - function: { name: "test", arguments: "{}" } - }] + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_abc", + type: "function", + function: { name: "test", arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", }, - finish_reason: "tool_calls" - }] + ], })}\n`, ].join(""); @@ -83,8 +87,77 @@ test("transformToOllama handles string tool_call id normally", async () => { const result = transformToOllama(mockResponse, "test-model"); const text = await result.text(); - const lines = text.trim().split("\n").map(l => JSON.parse(l)); + const lines = text.trim().split("\n").map((line) => JSON.parse(line)); - const toolCallLine = lines.find(l => l.message?.tool_calls); + const toolCallLine = lines.find((line) => line.message?.tool_calls); assert.ok(toolCallLine, "Should produce a tool call line"); }); + +test("transformToOllama merges multi-chunk numeric tool_call id", async () => { + const inputSSE = [ + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 12345, + type: "function", + function: { name: "test", arguments: '{"a":' }, + }, + ], + }, + }, + ], + })}\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 12345, + type: "function", + function: { arguments: "1}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + })}\n`, + ].join(""); + + const inputStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(inputSSE)); + controller.close(); + }, + }); + + const mockResponse = new Response(inputStream, { + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = transformToOllama(mockResponse, "test-model"); + const text = await result.text(); + const lines = text.trim().split("\n").map((line) => JSON.parse(line)); + const toolCallLines = lines.filter((line) => line.message?.tool_calls); + + assert.equal(toolCallLines.length, 1); + assert.equal(toolCallLines[0].message.tool_calls.length, 1); + assert.equal(toolCallLines[0].message.tool_calls[0].function.name, "test"); + assert.deepEqual(toolCallLines[0].message.tool_calls[0].function.arguments, { a: 1 }); +}); diff --git a/tests/unit/sse-parser-numeric-ids.test.ts b/tests/unit/sse-parser-numeric-ids.test.ts index aab9138409..485187cccb 100644 --- a/tests/unit/sse-parser-numeric-ids.test.ts +++ b/tests/unit/sse-parser-numeric-ids.test.ts @@ -77,3 +77,80 @@ test("parseSSEToResponsesOutput coerces numeric reasoning item id from increment assert.equal(typeof parsed.output[0].id, "string"); assert.equal(parsed.output[0].id, "101"); }); + +test("parseSSEToResponsesOutput coerces numeric function call call_id to string", () => { + const rawSSE = [ + 'data: {"type":"response.completed","response":{"id":"resp_call","model":"gpt-4.1","status":"completed","output":[{"id":789,"call_id":456,"type":"function_call","name":"foo","arguments":"{}"}]}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.output[0].id, "string"); + assert.equal(parsed.output[0].id, "789"); + assert.equal(typeof parsed.output[0].call_id, "string"); + assert.equal(parsed.output[0].call_id, "456"); +}); + +test("parseSSEToResponsesOutput preserves numeric item_id from function call deltas", () => { + const rawSSE = [ + 'data: {"type":"response.created","response":{"id":"resp_item","model":"gpt-4.1","status":"in_progress","output":[]}}', + 'data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":321,"delta":"{\\"a\\":"}', + 'data: {"type":"response.function_call_arguments.done","output_index":0,"item_id":321,"arguments":"{\\"a\\":1}","status":"completed"}', + 'data: {"type":"response.completed","response":{"id":"resp_item","model":"gpt-4.1","status":"completed","output":null}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.output[0].id, "string"); + assert.equal(parsed.output[0].id, "321"); +}); + +test("parseSSEToOpenAIResponse merges tool_call with id 0 and no index", () => { + const rawSSE = [ + 'data: {"id":"chatcmpl_zero","choices":[{"index":0,"delta":{"tool_calls":[{"id":0,"function":{"name":"foo","arguments":"{\\"a\\":"}}]},"finish_reason":null}]}', + 'data: {"id":"chatcmpl_zero","choices":[{"index":0,"delta":{"tool_calls":[{"id":0,"function":{"arguments":"1}"}}]},"finish_reason":"tool_calls"}]}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToOpenAIResponse(rawSSE, "fallback-model"); + + assert.equal(parsed.choices[0].message.tool_calls.length, 1); + assert.equal(typeof parsed.choices[0].message.tool_calls[0].id, "string"); + assert.equal(parsed.choices[0].message.tool_calls[0].id, "0"); + assert.equal(parsed.choices[0].message.tool_calls[0].function.name, "foo"); + assert.equal(parsed.choices[0].message.tool_calls[0].function.arguments, '{"a":1}'); +}); + +test("parseSSEToOpenAIResponse handles tool_call with negative numeric id", () => { + const rawSSE = [ + 'data: {"id":"chatcmpl_neg","choices":[{"index":0,"delta":{"tool_calls":[{"id":-1,"index":0,"function":{"name":"bar","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}', + "", + "data: [DONE]", + "", + ].join("\n"); + + const parsed = parseSSEToOpenAIResponse(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.choices[0].message.tool_calls[0].id, "string"); + assert.equal(parsed.choices[0].message.tool_calls[0].id, "-1"); +}); + +test("parseSSEToResponsesOutput coerces numeric call_id and item_id together", () => { + const rawSSE = [ + 'data: {"type":"response.created","response":{"id":"resp_comb","model":"gpt-4.1","status":"in_progress","output":[]}}', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":111,"call_id":222,"type":"function_call","status":"in_progress","name":"both","arguments":""}}', + 'data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":111,"delta":"{\\"x\\":"}', + 'data: {"type":"response.function_call_arguments.done","output_index":0,"item_id":111,"arguments":"{\\"x\\":1}","status":"completed"}', + 'data: {"type":"response.completed","response":{"id":"resp_comb","model":"gpt-4.1","status":"completed","output":null}}', + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToResponsesOutput(rawSSE, "fallback-model"); + + assert.equal(typeof parsed.output[0].id, "string"); + assert.equal(parsed.output[0].id, "111"); + assert.equal(typeof parsed.output[0].call_id, "string"); + assert.equal(parsed.output[0].call_id, "222"); +}); diff --git a/tests/unit/stream-numeric-ids.test.ts b/tests/unit/stream-numeric-ids.test.ts index 346b88a5f0..ce82ad9ce3 100644 --- a/tests/unit/stream-numeric-ids.test.ts +++ b/tests/unit/stream-numeric-ids.test.ts @@ -105,3 +105,400 @@ test("createSSEStream passthrough coerces numeric tool_call id to string", async assert.equal(typeof finalId, "string", "tool_call.id in final message should be a string"); assert.equal(finalId, "12345"); }); + +test("createSSEStream passthrough preserves numeric top-level id as string", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: 123, + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { content: "Hello" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: 123, + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + } + ); + + const lines = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")); + + assert.ok(lines.length > 0, "expected streamed chunks"); + for (const line of lines) { + const payload = JSON.parse(line.slice(6)); + assert.equal(typeof payload.id, "string", "top-level chunk id should be a string"); + assert.equal(payload.id, "123"); + assert.notEqual(payload.id.startsWith("chatcmpl-"), true); + } +}); + +test("createSSEStream responses passthrough coerces numeric ids to strings", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + type: "response.created", + response: { + id: 987, + model: "gpt-4.1-mini", + status: "in_progress", + output: [], + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.output_item.added", + response_id: 987, + output_index: 0, + item: { + id: 456, + call_id: 654, + type: "function_call", + status: "in_progress", + name: "lookup", + arguments: "", + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.function_call_arguments.delta", + response_id: 987, + item_id: 456, + output_index: 0, + delta: '{"q":', + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.function_call_arguments.done", + response_id: 987, + item_id: 456, + output_index: 0, + arguments: '{"q":1}', + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: 987, + model: "gpt-4.1-mini", + status: "completed", + output: [ + { + id: 456, + call_id: 654, + type: "function_call", + status: "completed", + name: "lookup", + arguments: '{"q":1}', + }, + ], + }, + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { input: "hello" }, + } + ); + + const payloads = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")) + .map((line) => JSON.parse(line.slice(6))); + + const created = payloads.find((payload) => payload.type === "response.created"); + assert.equal(typeof created.response.id, "string"); + assert.equal(created.response.id, "987"); + + const added = payloads.find((payload) => payload.type === "response.output_item.added"); + assert.equal(typeof added.response_id, "string"); + assert.equal(added.response_id, "987"); + assert.equal(typeof added.item.id, "string"); + assert.equal(added.item.id, "456"); + assert.equal(typeof added.item.call_id, "string"); + assert.equal(added.item.call_id, "654"); + + const delta = payloads.find((payload) => payload.type === "response.function_call_arguments.delta"); + assert.equal(typeof delta.response_id, "string"); + assert.equal(delta.response_id, "987"); + assert.equal(typeof delta.item_id, "string"); + assert.equal(delta.item_id, "456"); + + const completed = payloads.find((payload) => payload.type === "response.completed"); + assert.equal(typeof completed.response.id, "string"); + assert.equal(completed.response.id, "987"); + assert.equal(typeof completed.response.output[0].id, "string"); + assert.equal(completed.response.output[0].id, "456"); + assert.equal(typeof completed.response.output[0].call_id, "string"); + assert.equal(completed.response.output[0].call_id, "654"); +}); + +test("createSSEStream responses passthrough does not normalize unrelated top-level id", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + type: "response.output_text.delta", + id: 1, + response_id: 987, + item_id: 456, + delta: "x", + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { input: "hello" }, + } + ); + + const payloads = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")) + .map((line) => JSON.parse(line.slice(6))); + + const delta = payloads.find((p) => p.type === "response.output_text.delta"); + assert.equal(typeof delta.id, "number", "unrelated top-level id should stay numeric"); + assert.equal(delta.id, 1); + assert.equal(typeof delta.response_id, "string"); + assert.equal(delta.response_id, "987"); + assert.equal(typeof delta.item_id, "string"); + assert.equal(delta.item_id, "456"); +}); + +test("createSSEStream passthrough normalizes numeric id in final chunk without trailing newline", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: 123, + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { content: "Hello" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: 123, + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + } + ); + + const lines = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")); + + for (const line of lines) { + const payload = JSON.parse(line.slice(6)); + if (payload.id) { + assert.equal(typeof payload.id, "string", "top-level chunk id should be a string"); + assert.equal(payload.id, "123"); + } + } +}); + +test("createSSEStream responses passthrough normalizes numeric ids in final chunk without trailing newline", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + type: "response.created", + response: { id: 987, model: "gpt-4.1-mini", status: "in_progress", output: [] }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: 987, + model: "gpt-4.1-mini", + status: "completed", + output: [ + { + id: 456, + call_id: 654, + type: "function_call", + status: "completed", + name: "lookup", + arguments: "{}", + }, + ], + }, + })}`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { input: "hello" }, + } + ); + + const payloads = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")) + .map((line) => JSON.parse(line.slice(6))); + + const completed = payloads.find((p) => p.type === "response.completed"); + assert.equal(typeof completed.response.id, "string"); + assert.equal(completed.response.id, "987"); + assert.equal(typeof completed.response.output[0].id, "string"); + assert.equal(completed.response.output[0].id, "456"); + assert.equal(typeof completed.response.output[0].call_id, "string"); + assert.equal(completed.response.output[0].call_id, "654"); +}); + +test("createSSEStream passthrough coerces tool_call id 0 without index", async () => { + let onCompletePayload = null; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "cmpl_0", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant", content: "" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "cmpl_0", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + id: 0, + type: "function", + function: { name: "zero", arguments: '{"path":"/tmp"}' }, + }, + ], + }, + }, + ], + })}\n\n`, + `data: ${JSON.stringify({ + id: "cmpl_0", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "openai", + model: "gpt-4.1-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const lines = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")); + let sawStreamedToolCall = false; + for (const line of lines) { + const payload = JSON.parse(line.slice(6)); + const tc = payload?.choices?.[0]?.delta?.tool_calls?.[0]; + if (tc?.id != null) { + assert.equal(typeof tc.id, "string", "tool_call.id should be a string"); + assert.equal(tc.id, "0"); + sawStreamedToolCall = true; + } + } + assert.equal(sawStreamedToolCall, true, "expected streamed tool_call.id to be present"); + + const finalId = onCompletePayload?.responseBody?.choices?.[0]?.message?.tool_calls?.[0]?.id; + assert.equal(typeof finalId, "string", "tool_call.id in final message should be a string"); + assert.equal(finalId, "0"); +}); + +test("createSSEStream Claude passthrough does not normalize numeric ids", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + type: "message_start", + message: { + id: 987, + type: "message", + role: "assistant", + content: [], + model: "claude-3-5-sonnet", + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { + id: 456, + type: "text", + text: "", + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Hello", + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "message_stop", + })}\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.ANTHROPIC, + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + body: { messages: [{ role: "user", content: "hello" }] }, + } + ); + + const payloads = text + .trim() + .split("\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")) + .map((line) => JSON.parse(line.slice(6))); + + const start = payloads.find((p) => p.type === "message_start"); + assert.equal(start.message.id, 987, "Claude message id should remain numeric"); + assert.equal(typeof start.message.id, "number"); + + const contentStart = payloads.find((p) => p.type === "content_block_start"); + assert.equal(contentStart.content_block.id, 456, "Claude content block id should remain numeric"); + assert.equal(typeof contentStart.content_block.id, "number"); +});