Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions dist/llm-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", {
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
39 changes: 29 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", {
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions test/fixtures/streams/openai-responses.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
{ "event": "response.output_text.delta", "data": { "type": "response.output_text.delta", "item_id": "<message-1>", "output_index": 0, "content_index": 0, "delta": " world" } },
{ "event": "response.output_text.done", "data": { "type": "response.output_text.done", "item_id": "<message-1>", "output_index": 0, "content_index": 0, "text": "Hello world" } },
{ "event": "response.content_part.done", "data": { "type": "response.content_part.done", "item_id": "<message-1>", "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": "<message-1>", "type": "message", "status": "completed", "role": "assistant" } } },
{ "event": "response.completed", "data": { "type": "response.completed", "response": { "id": "<response-1>", "object": "response", "created_at": "<timestamp>", "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": "<message-1>", "type": "message", "status": "completed", "role": "assistant", "content": [{ "type": "output_text", "text": "Hello world", "annotations": [] }] } } },
{ "event": "response.completed", "data": { "type": "response.completed", "response": { "id": "<response-1>", "object": "response", "created_at": "<timestamp>", "status": "completed", "model": "test/responses-model", "output": [{ "id": "<message-1>", "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": "<response-1>", "object": "response", "created_at": "<timestamp>", "status": "in_progress", "model": "test/responses-model", "output": [] } } },
Expand All @@ -20,6 +20,6 @@
{ "event": "response.function_call_arguments.delta", "data": { "type": "response.function_call_arguments.delta", "item_id": "<function-item-2>", "output_index": 1, "delta": "{\"timezone\":\"Europe/Paris\"}" } },
{ "event": "response.function_call_arguments.done", "data": { "type": "response.function_call_arguments.done", "item_id": "<function-item-2>", "output_index": 1, "arguments": "{\"timezone\":\"Europe/Paris\"}" } },
{ "event": "response.output_item.done", "data": { "type": "response.output_item.done", "output_index": 1, "item": { "id": "<function-item-2>", "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": "<response-1>", "object": "response", "created_at": "<timestamp>", "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": "<response-1>", "object": "response", "created_at": "<timestamp>", "status": "completed", "model": "test/responses-model", "output": [{ "id": "<function-item-1>", "type": "function_call", "status": "completed", "call_id": "call_weather", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }, { "id": "<function-item-2>", "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 } } } }
]
}
Loading