diff --git a/dist/llm-proxy.js b/dist/llm-proxy.js index 0a30346..1e4505b 100644 --- a/dist/llm-proxy.js +++ b/dist/llm-proxy.js @@ -3479,7 +3479,7 @@ data: ${JSON.stringify(data)} } const toolCalls = streamResult.toolCalls ?? []; if (toolCalls.length > 0) { - toolCalls.forEach((call, index) => { + const outputItems = toolCalls.map((call, index) => { const args = JSON.stringify(call.arguments ?? {}); const callItemID = `fc_${crypto.randomUUID().replace(/-/g, "")}`; const outputIndex = index; @@ -3513,20 +3513,22 @@ data: ${JSON.stringify(data)} arguments: args }) ); + const completedItem = { + id: callItemID, + type: "function_call", + status: "completed", + call_id: call.id, + name: call.name, + arguments: args + }; queue.enqueue( sseEvent("response.output_item.done", { type: "response.output_item.done", output_index: outputIndex, - item: { - id: callItemID, - type: "function_call", - status: "completed", - call_id: call.id, - name: call.name, - arguments: args - } + item: completedItem }) ); + return completedItem; }); queue.enqueue( sseEvent("response.completed", { @@ -3537,6 +3539,12 @@ data: ${JSON.stringify(data)} created_at: now, status: "completed", model: model.id, + // The Responses API includes the final `output` array on + // response.completed (mirroring the empty one on + // response.created). Clients such as langchainjs-openai + // build the aggregated message from it and throw on + // `response.output.map(...)` when the field is missing. + output: outputItems, usage: { input_tokens: streamResult.tokens.input, output_tokens: streamResult.tokens.output, @@ -3567,11 +3575,18 @@ data: ${JSON.stringify(data)} }) ); } + const completedMessage = { + id: itemID, + type: "message", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: accumulatedText, annotations: [] }] + }; queue.enqueue( sseEvent("response.output_item.done", { type: "response.output_item.done", output_index: 0, - item: { id: itemID, type: "message", status: "completed", role: "assistant" } + item: completedMessage }) ); queue.enqueue( @@ -3583,6 +3598,10 @@ data: ${JSON.stringify(data)} created_at: now, status: "completed", model: model.id, + // See the tool-call branch above: `output` is required here, + // and the message item must carry its content so clients can + // build the final answer from `response.output`. + output: [completedMessage], usage: { input_tokens: streamResult.tokens.input, output_tokens: streamResult.tokens.output, diff --git a/index.js b/index.js index ec5adcd..8bc3978 100644 --- a/index.js +++ b/index.js @@ -2535,7 +2535,7 @@ export function createProxyFetchHandler(client) { const toolCalls = streamResult.toolCalls ?? [] if (toolCalls.length > 0) { // Each parallel tool call is its own output item with a distinct output_index. - toolCalls.forEach((call, index) => { + const outputItems = toolCalls.map((call, index) => { const args = JSON.stringify(call.arguments ?? {}) const callItemID = `fc_${crypto.randomUUID().replace(/-/g, "")}` const outputIndex = index @@ -2569,20 +2569,22 @@ export function createProxyFetchHandler(client) { arguments: args, }), ) + const completedItem = { + id: callItemID, + type: "function_call", + status: "completed", + call_id: call.id, + name: call.name, + arguments: args, + } queue.enqueue( sseEvent("response.output_item.done", { type: "response.output_item.done", output_index: outputIndex, - item: { - id: callItemID, - type: "function_call", - status: "completed", - call_id: call.id, - name: call.name, - arguments: args, - }, + item: completedItem, }), ) + return completedItem }) queue.enqueue( sseEvent("response.completed", { @@ -2593,6 +2595,12 @@ export function createProxyFetchHandler(client) { created_at: now, status: "completed", model: model.id, + // The Responses API includes the final `output` array on + // response.completed (mirroring the empty one on + // response.created). Clients such as langchainjs-openai + // build the aggregated message from it and throw on + // `response.output.map(...)` when the field is missing. + output: outputItems, usage: { input_tokens: streamResult.tokens.input, output_tokens: streamResult.tokens.output, @@ -2626,11 +2634,18 @@ export function createProxyFetchHandler(client) { }), ) } + const completedMessage = { + id: itemID, + type: "message", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: accumulatedText, annotations: [] }], + } queue.enqueue( sseEvent("response.output_item.done", { type: "response.output_item.done", output_index: 0, - item: { id: itemID, type: "message", status: "completed", role: "assistant" }, + item: completedMessage, }), ) queue.enqueue( @@ -2642,6 +2657,10 @@ export function createProxyFetchHandler(client) { created_at: now, status: "completed", model: model.id, + // See the tool-call branch above: `output` is required here, + // and the message item must carry its content so clients can + // build the final answer from `response.output`. + output: [completedMessage], usage: { input_tokens: streamResult.tokens.input, output_tokens: streamResult.tokens.output, diff --git a/index.test.js b/index.test.js index 636bc7d..c7cb9c6 100644 --- a/index.test.js +++ b/index.test.js @@ -1427,6 +1427,47 @@ test("POST /v1/responses stream: true returns SSE lifecycle events", async () => assert.ok(text.includes("response.completed")) }) +test("POST /v1/responses stream: true includes the final output array on response.completed (text turn)", async () => { + const events = [ + { + type: "message.part.delta", + properties: { sessionID: "sess-123", field: "text", delta: "The answer" }, + }, + { + type: "message.part.delta", + properties: { sessionID: "sess-123", field: "text", delta: " is 42." }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + const handler = createProxyFetchHandler(createStreamingClient(events)) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + input: "What is 6 times 7?", + }), + }) + + const response = await handler(request) + const completed = parseSseStream(await response.text()) + .find((entry) => entry.event === "response.completed") + + // Clients such as langchainjs-openai build the final message from + // response.output and throw on `response.output.map(...)` when it is missing. + assert.ok(completed) + const output = completed.data.response.output + assert.ok(Array.isArray(output)) + assert.equal(output.length, 1) + assert.equal(output[0].type, "message") + assert.equal(output[0].role, "assistant") + assert.deepEqual(output[0].content, [ + { type: "output_text", text: "The answer is 42.", annotations: [] }, + ]) +}) + test("POST /v1/responses stream: true emits content_part.done with accumulated text per OpenAI spec", async () => { const events = [ { @@ -2970,6 +3011,36 @@ test("POST /v1/responses stream: true emits function_call SSE events", async () assert.ok(text.includes('"type":"function_call"')) }) +test("POST /v1/responses stream: true includes function_call items in response.completed output", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" } }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + input: "What's the weather in NYC?", + tools: [{ type: "function", name: "get_weather" }], + }), + }) + + const response = await handler(request) + const completed = parseSseStream(await response.text()) + .find((entry) => entry.event === "response.completed") + + // Same contract as the text turn: the aggregated output must be present. + assert.ok(completed) + const output = completed.data.response.output + assert.ok(Array.isArray(output)) + assert.equal(output.length, 1) + assert.equal(output[0].type, "function_call") + assert.equal(output[0].status, "completed") + assert.equal(output[0].call_id, "call_1") + assert.equal(output[0].name, "get_weather") + assert.deepEqual(JSON.parse(output[0].arguments), { city: "NYC" }) +}) + test("tool_choice: none disables tool calling even when tools are supplied", async () => { const events = [{ type: "session.idle", properties: { sessionID: "sess-123" } }] const client = createStreamingClient(events) diff --git a/test/fixtures/streams/openai-responses.json b/test/fixtures/streams/openai-responses.json index 09009c4..cc65d25 100644 --- a/test/fixtures/streams/openai-responses.json +++ b/test/fixtures/streams/openai-responses.json @@ -7,8 +7,8 @@ { "event": "response.output_text.delta", "data": { "type": "response.output_text.delta", "item_id": "", "output_index": 0, "content_index": 0, "delta": " world" } }, { "event": "response.output_text.done", "data": { "type": "response.output_text.done", "item_id": "", "output_index": 0, "content_index": 0, "text": "Hello world" } }, { "event": "response.content_part.done", "data": { "type": "response.content_part.done", "item_id": "", "output_index": 0, "content_index": 0, "part": { "type": "output_text", "text": "Hello world", "annotations": [] } } }, - { "event": "response.output_item.done", "data": { "type": "response.output_item.done", "output_index": 0, "item": { "id": "", "type": "message", "status": "completed", "role": "assistant" } } }, - { "event": "response.completed", "data": { "type": "response.completed", "response": { "id": "", "object": "response", "created_at": "", "status": "completed", "model": "test/responses-model", "usage": { "input_tokens": 11, "output_tokens": 4, "total_tokens": 15 } } } } + { "event": "response.output_item.done", "data": { "type": "response.output_item.done", "output_index": 0, "item": { "id": "", "type": "message", "status": "completed", "role": "assistant", "content": [{ "type": "output_text", "text": "Hello world", "annotations": [] }] } } }, + { "event": "response.completed", "data": { "type": "response.completed", "response": { "id": "", "object": "response", "created_at": "", "status": "completed", "model": "test/responses-model", "output": [{ "id": "", "type": "message", "status": "completed", "role": "assistant", "content": [{ "type": "output_text", "text": "Hello world", "annotations": [] }] }], "usage": { "input_tokens": 11, "output_tokens": 4, "total_tokens": 15 } } } } ], "parallelTools": [ { "event": "response.created", "data": { "type": "response.created", "response": { "id": "", "object": "response", "created_at": "", "status": "in_progress", "model": "test/responses-model", "output": [] } } }, @@ -20,6 +20,6 @@ { "event": "response.function_call_arguments.delta", "data": { "type": "response.function_call_arguments.delta", "item_id": "", "output_index": 1, "delta": "{\"timezone\":\"Europe/Paris\"}" } }, { "event": "response.function_call_arguments.done", "data": { "type": "response.function_call_arguments.done", "item_id": "", "output_index": 1, "arguments": "{\"timezone\":\"Europe/Paris\"}" } }, { "event": "response.output_item.done", "data": { "type": "response.output_item.done", "output_index": 1, "item": { "id": "", "type": "function_call", "status": "completed", "call_id": "call_time", "name": "get_time", "arguments": "{\"timezone\":\"Europe/Paris\"}" } } }, - { "event": "response.completed", "data": { "type": "response.completed", "response": { "id": "", "object": "response", "created_at": "", "status": "completed", "model": "test/responses-model", "usage": { "input_tokens": 11, "output_tokens": 4, "total_tokens": 15 } } } } + { "event": "response.completed", "data": { "type": "response.completed", "response": { "id": "", "object": "response", "created_at": "", "status": "completed", "model": "test/responses-model", "output": [{ "id": "", "type": "function_call", "status": "completed", "call_id": "call_weather", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }, { "id": "", "type": "function_call", "status": "completed", "call_id": "call_time", "name": "get_time", "arguments": "{\"timezone\":\"Europe/Paris\"}" }], "usage": { "input_tokens": 11, "output_tokens": 4, "total_tokens": 15 } } } } ] }