mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
fix(sse): normalize numeric provider ids to strings (#3451)
Integrated into release/v3.8.18
This commit is contained in:
@@ -130,7 +130,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
|
||||
const getToolCallKey = (toolCall: Record<string, unknown>) => {
|
||||
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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,6 +62,81 @@ export { COLORS, formatSSE };
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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, unknown>): 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<string, unknown>)
|
||||
: 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}`;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user