Skip to content

Commit b7402ca

Browse files
authored
fix: emit content_part.done and populate output_text.done.text per Responses API spec (#49)
The /v1/responses streaming handler violates the OpenAI Responses API SSE lifecycle spec in two ways: 1. response.content_part.done is never emitted. Per the spec (https://platform.openai.com/docs/api-reference/responses-streaming), the event sequence for a text content part should be: content_part.added -> output_text.delta* -> output_text.done -> content_part.done -> output_item.done 2. response.output_text.done is emitted with text: "" instead of the accumulated output text. The spec requires the final content. Accumulate delta tokens in a local variable at the streaming call site, emit the missing response.content_part.done event with the accumulated text in part.text, and populate output_text.done.text with the same accumulated content. Gate the new content_part.done event on at least one delta having been received, keeping the content-part added/done lifecycle symmetric. Adds one regression test in index.test.js that asserts: - output_text.done.text equals the accumulated deltas - content_part.done event is present with part.text populated - correct ordering (output_text.done < content_part.done < output_item.done) Closes #48
1 parent 2eb182f commit b7402ca

2 files changed

Lines changed: 95 additions & 1 deletion

File tree

index.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,9 @@ export function createProxyFetchHandler(client) {
923923
)
924924

925925
let partIndex = 0
926+
// Accumulate delta tokens so we can populate `text` on output_text.done and content_part.done per the
927+
// OpenAI Responses API SSE spec (https://platform.openai.com/docs/api-reference/responses-streaming).
928+
let accumulatedText = ""
926929
const runPromise = executePromptStreaming(
927930
client,
928931
model,
@@ -941,6 +944,7 @@ export function createProxyFetchHandler(client) {
941944
)
942945
partIndex++
943946
}
947+
accumulatedText += delta
944948
queue.enqueue(
945949
sseEvent("response.output_text.delta", {
946950
type: "response.output_text.delta",
@@ -959,9 +963,22 @@ export function createProxyFetchHandler(client) {
959963
item_id: itemID,
960964
output_index: 0,
961965
content_index: 0,
962-
text: "",
966+
text: accumulatedText,
963967
}),
964968
)
969+
if (partIndex > 0) {
970+
// Only emit content_part.done if content_part.added was emitted (i.e. at least one delta arrived).
971+
// Keeps the content-part lifecycle symmetric per the OpenAI Responses API spec.
972+
queue.enqueue(
973+
sseEvent("response.content_part.done", {
974+
type: "response.content_part.done",
975+
item_id: itemID,
976+
output_index: 0,
977+
content_index: 0,
978+
part: { type: "output_text", text: accumulatedText, annotations: [] },
979+
}),
980+
)
981+
}
965982
queue.enqueue(
966983
sseEvent("response.output_item.done", {
967984
type: "response.output_item.done",

index.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,21 @@ function createStreamingClient(chunks) {
8080
}
8181
}
8282

83+
function parseSseStream(text) {
84+
// Parses SSE `event: <name>\ndata: <json>\n\n` chunks into an ordered array.
85+
// Local to this test file; not exported.
86+
return text
87+
.split("\n\n")
88+
.filter((block) => block.trim())
89+
.map((block) => {
90+
const eventLine = block.match(/^event: (.+)$/m)
91+
const dataLine = block.match(/^data: (.+)$/m)
92+
if (!eventLine || !dataLine) return null
93+
return { event: eventLine[1], data: JSON.parse(dataLine[1]) }
94+
})
95+
.filter(Boolean)
96+
}
97+
8398
test("OPTIONS preflight returns CORS headers", async () => {
8499
const handler = createProxyFetchHandler(createClient())
85100
const request = new Request("http://127.0.0.1:4010/v1/models", {
@@ -1108,6 +1123,68 @@ test("POST /v1/responses stream: true returns SSE lifecycle events", async () =>
11081123
assert.ok(text.includes("response.completed"))
11091124
})
11101125

1126+
test("POST /v1/responses stream: true emits content_part.done with accumulated text per OpenAI spec", async () => {
1127+
const events = [
1128+
{
1129+
type: "message.part.updated",
1130+
properties: {
1131+
part: { sessionID: "sess-123", type: "text" },
1132+
delta: "The answer",
1133+
},
1134+
},
1135+
{
1136+
type: "message.part.updated",
1137+
properties: {
1138+
part: { sessionID: "sess-123", type: "text" },
1139+
delta: " is 42.",
1140+
},
1141+
},
1142+
{ type: "session.idle", properties: { sessionID: "sess-123" } },
1143+
]
1144+
1145+
const handler = createProxyFetchHandler(createStreamingClient(events))
1146+
const request = new Request("http://127.0.0.1:4010/v1/responses", {
1147+
method: "POST",
1148+
headers: { "content-type": "application/json" },
1149+
body: JSON.stringify({
1150+
model: "gpt-4o",
1151+
stream: true,
1152+
input: "What is 6 times 7?",
1153+
}),
1154+
})
1155+
1156+
const response = await handler(request)
1157+
const text = await response.text()
1158+
const parsed = parseSseStream(text)
1159+
const names = parsed.map((e) => e.event)
1160+
1161+
// Discriminator 1 (gap #3): output_text.done.text must be the accumulated content
1162+
const outputTextDone = parsed.find((e) => e.event === "response.output_text.done")
1163+
assert.ok(outputTextDone, "response.output_text.done event must be present")
1164+
assert.equal(outputTextDone.data.text, "The answer is 42.")
1165+
1166+
// Discriminator 2 (gap #2): content_part.done must be present with populated part.text
1167+
const contentPartDone = parsed.find((e) => e.event === "response.content_part.done")
1168+
assert.ok(contentPartDone, "response.content_part.done event must be present")
1169+
assert.equal(contentPartDone.data.part.type, "output_text")
1170+
assert.equal(contentPartDone.data.part.text, "The answer is 42.")
1171+
assert.deepEqual(contentPartDone.data.part.annotations, [])
1172+
1173+
// Ordering: output_text.done -> content_part.done -> output_item.done
1174+
const idxOutputTextDone = names.indexOf("response.output_text.done")
1175+
const idxContentPartDone = names.indexOf("response.content_part.done")
1176+
const idxOutputItemDone = names.indexOf("response.output_item.done")
1177+
assert.ok(idxOutputTextDone >= 0, "output_text.done must be in the stream")
1178+
assert.ok(
1179+
idxContentPartDone > idxOutputTextDone,
1180+
"content_part.done must follow output_text.done",
1181+
)
1182+
assert.ok(
1183+
idxOutputItemDone > idxContentPartDone,
1184+
"output_item.done must follow content_part.done",
1185+
)
1186+
})
1187+
11111188
test("POST /v1/responses stream: true with session.error emits response.failed", async () => {
11121189
const events = [
11131190
{

0 commit comments

Comments
 (0)