Skip to content

Commit 25820e8

Browse files
authored
Merge pull request #217 from yexisu/fix/issue-130-close-stream
fix: stop consuming OpenAI Responses stream after the terminal event
2 parents db29ac2 + 1452c36 commit 25820e8

7 files changed

Lines changed: 174 additions & 5 deletions

File tree

docs/spec/03-runtime/11-provider-model-system.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,19 @@ model-level pin the provider-wide style applies unchanged.
610610

611611
This is the **universal escape hatch** guaranteeing market coverage beyond native integrations.
612612

613+
### 16.1 Responses stream termination (pi-ai patch)
614+
615+
The OpenAI Responses adapter must treat `response.completed` (and
616+
`response.incomplete`) as the end of the stream: after finalizing the
617+
response, it stops consuming the stream instead of awaiting the server's
618+
TCP FIN. Upstream pi-ai keeps iterating until the server closes the
619+
connection, which hangs the turn behind reverse proxies that hold the idle
620+
connection open. Until the fix ships upstream, `patches/` carries a pnpm
621+
patch on `@earendil-works/pi-ai@0.85.1` that breaks the event loop on the
622+
terminal event (the OpenAI SDK aborts the underlying request when the
623+
consumer stops iterating). Drop the patch once a pi-ai release includes the
624+
fix.
625+
613626
## 17. Multi-provider product rules
614627

615628
1. Multiple providers of the same `vendorKey` are allowed and independent (for

docs/spec/06-delivery/04-e2e-test-plan.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,29 @@ Each scenario is documented in this format:
660660
- **Status**: Unit-covered (preset matching, catalog aliases, Completions
661661
compat); rendered UI scenario Draft
662662

663+
#### E2E-248: Responses turn completes without waiting for the server to close the connection
664+
665+
- **Preconditions**: A provider whose model pins `api: "openai-responses"` (or
666+
a provider with `apiStyle: "responses"`) is configured; the endpoint is
667+
fronted by a proxy that holds the HTTP connection open after the final
668+
SSE event (a local reverse proxy or a stub server that never sends FIN).
669+
- **Steps**: 1) Start a session with that model and send a short prompt. 2)
670+
Capture the SSE frames and confirm the server emitted
671+
`response.completed` with `status: "completed"` and usage. 3) Keep the
672+
stub/proxy connection open without sending a TCP FIN. 4) Observe the
673+
assistant turn state and send a follow-up prompt.
674+
- **Expected**: The turn completes as soon as `response.completed` is
675+
finalized: usage is recorded, `stopReason` is `stop`, and the client stops
676+
consuming the stream (the underlying request is aborted) instead of
677+
blocking on the idle connection. The composer becomes idle immediately and
678+
the follow-up turn starts normally. The stream must not hang when the
679+
server never closes the connection.
680+
- **Specs linked**: `03-runtime/11-provider-model-system.md` (§16.1)
681+
- **Acceptance**: B (provider Responses compatibility)
682+
- **Milestone**: M2
683+
- **Status**: Unit-covered (stream processor terminates on the terminal
684+
event via the pi-ai patch); live-proxy scenario Draft
685+
663686
#### E2E-005E: DeepSeek thinking replay includes reasoning_content on aggregator endpoints
664687

665688
- **Preconditions**: An OpenAI-compatible provider whose base URL is not

docs/zh-CN/spec/03-runtime/11-provider-model-system.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,17 @@ UI 可能会显示层级提示,但默认情况下不得硬阻止未知模型
542542

543543
目录条目还可以额外固定模型级 wire API(例如 `api: "openai-responses"`)。存在时它优先于 provider`apiStyle`,因此 `opencode_go` 下的 responses-only 模型会走 Responses adapter 而非 Chat Completions;没有模型级固定时保持 provider 级风格不变。
544544

545+
### 16.1 Responses 流终止(pi-ai 补丁)
546+
547+
OpenAI Responses 适配器必须把 `response.completed`(以及
548+
`response.incomplete`)视为流的终点:完成响应收尾后即停止消费流,
549+
而不是继续等待服务端的 TCP FIN。上游 pi-ai 会一直迭代直到服务端关闭
550+
连接,在保持空闲连接不关的反向代理后面会导致整个回合挂起。在该修复
551+
随上游发布之前,`patches/` 通过 pnpm patch 修改
552+
`@earendil-works/pi-ai@0.85.1`,在终态事件处跳出事件循环(消费方停止
553+
迭代时 OpenAI SDK 会中止底层请求)。待 pi-ai 发布包含该修复的版本后
554+
移除补丁。
555+
545556
## 17. 多提供商产品规则
546557

547558
1. 允许多个提供商具有相同的供应商密钥(例如两个 OpenRouter 帐户)。
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { Context, Model } from "@earendil-works/pi-ai";
3+
import { stream } from "@earendil-works/pi-ai/api/openai-responses";
4+
5+
const model: Model<"openai-responses"> = {
6+
id: "responses-test",
7+
name: "Responses Test",
8+
api: "openai-responses",
9+
provider: "acme",
10+
baseUrl: "https://api.acme.test/v1",
11+
reasoning: false,
12+
input: ["text"],
13+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
14+
contextWindow: 128000,
15+
maxTokens: 4096,
16+
};
17+
18+
const context: Context = {
19+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
20+
};
21+
22+
const completedEvent = {
23+
type: "response.completed",
24+
sequence_number: 2,
25+
response: {
26+
object: "response",
27+
id: "resp_1",
28+
created_at: 1,
29+
model: "responses-test",
30+
output: [
31+
{
32+
id: "item_1",
33+
type: "message",
34+
role: "assistant",
35+
content: [{ type: "output_text", text: "hello" }],
36+
status: "completed",
37+
},
38+
],
39+
status: "completed",
40+
usage: {
41+
input_tokens: 5,
42+
input_tokens_details: { cached_tokens: 0 },
43+
output_tokens: 2,
44+
output_tokens_details: { reasoning_tokens: 0 },
45+
total_tokens: 7,
46+
},
47+
},
48+
output_index: 0,
49+
};
50+
51+
/** SSE body that emits the terminal event and then never ends. */
52+
function hangAfterTerminalBody(): ReadableStream<Uint8Array> {
53+
const encoder = new TextEncoder();
54+
return new ReadableStream({
55+
start(controller) {
56+
controller.enqueue(
57+
encoder.encode(
58+
`event: response.created\ndata: ${JSON.stringify({ type: "response.created", sequence_number: 0, response: { id: "resp_1" } })}\n\n`,
59+
),
60+
);
61+
controller.enqueue(
62+
encoder.encode(
63+
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", sequence_number: 1, output_index: 0, content_index: 0, delta: "hello" })}\n\n`,
64+
),
65+
);
66+
controller.enqueue(
67+
encoder.encode(`event: response.completed\ndata: ${JSON.stringify(completedEvent)}\n\n`),
68+
);
69+
// Terminal event delivered; never enqueue again and never close().
70+
},
71+
});
72+
}
73+
74+
describe("OpenAI Responses stream termination (issue #130)", () => {
75+
it("completes the turn without waiting for the server to close the connection", async () => {
76+
const streamBody = hangAfterTerminalBody();
77+
const fetchImpl = async () =>
78+
new Response(streamBody, {
79+
status: 200,
80+
headers: { "content-type": "text/event-stream" },
81+
});
82+
83+
const events = await stream(model, context, { apiKey: "sk-test", fetch: fetchImpl as any });
84+
const seen: string[] = [];
85+
// Reading to completion must not hang: with the fix the stream consumer
86+
// stops after the terminal event, so `result()` resolves promptly.
87+
const result = await Promise.race([
88+
events.result(),
89+
new Promise<never>((_, reject) =>
90+
setTimeout(() => reject(new Error("stream hung after response.completed")), 5000),
91+
),
92+
]);
93+
94+
for await (const event of events) {
95+
seen.push(event.type);
96+
}
97+
98+
expect(seen).toContain("done");
99+
expect(result.stopReason).toBe("stop");
100+
expect(result.usage.totalTokens).toBe(7);
101+
}, 10000);
102+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
diff --git a/dist/api/openai-responses-shared.js b/dist/api/openai-responses-shared.js
2+
index 43e463dbfd1e6cc437ebc680468036f47678de26..364c8c0426d12543c1b64daa8e6eaa22f35f6f60 100644
3+
--- a/dist/api/openai-responses-shared.js
4+
+++ b/dist/api/openai-responses-shared.js
5+
@@ -636,6 +636,10 @@ export async function processResponsesStream(openaiStream, output, stream, model
6+
}
7+
else if (event.type === "response.completed" || event.type === "response.incomplete") {
8+
finalizeResponse(event.response);
9+
+ // The response is fully delivered: stop consuming the stream so the
10+
+ // request does not hang when the server (or a reverse proxy) never
11+
+ // closes the connection after the terminal event.
12+
+ break;
13+
}
14+
else if (event.type === "error") {
15+
throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error");

pnpm-lock.yaml

Lines changed: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ minimumReleaseAgeExclude:
4242
- '@earendil-works/pi-agent-core@0.85.1'
4343
- '@earendil-works/pi-ai@0.85.1'
4444
- '@earendil-works/pi-telemetry@0.85.1'
45+
patchedDependencies:
46+
'@earendil-works/pi-ai@0.85.1': patches/@earendil-works__pi-ai@0.85.1.patch

0 commit comments

Comments
 (0)