Skip to content

Commit 7263e05

Browse files
Fix streaming reliability: BrokenPipeError, tool call visibility, keepalive
Three fixes for streaming reliability issues found in session 019eee1b-bbf5-7992-a133-f3a61c0ed3dc: 1. BrokenPipeError crash: When Codex aborts a turn, it closes the connection. The proxy kept trying to write SSE events and crashed. Now send_event() catches BrokenPipeError, sets client_alive=False, and silently skips further writes. Prevents proxy crashes and allows clean turn aborts. 2. Tool calls showing as "thinking": The reasoning item stayed status=in_progress the entire time tool calls were being made. The UI saw reasoning still active and showed "thinking" instead of the tool calls. Now the reasoning item is closed (status= completed) before emitting tool call output_item.added events. 3. SSE keepalive: When the upstream model thinks for 30+ seconds before responding, Codex timed out waiting for content. Now a background thread sends SSE comment lines (": keepalive") every 15s while waiting for the upstream first byte, keeping the connection alive. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent bc7528b commit 7263e05

1 file changed

Lines changed: 44 additions & 4 deletions

File tree

src/opencode_go_proxy/app.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,24 @@ def handle_streaming_request(payload: Json, config: ProxyConfig, request_id: str
196196
response_id = new_response_id()
197197
model = request_model or DEFAULT_MODEL
198198

199+
client_alive = True
200+
199201
def send_event(event: Json) -> None:
200-
wfile.write(b"data: " + json.dumps(event, separators=(",", ":")).encode("utf-8") + b"\n\n")
201-
wfile.flush()
202+
nonlocal client_alive
203+
if not client_alive:
204+
return
205+
try:
206+
wfile.write(b"data: " + json.dumps(event, separators=(",", ":")).encode("utf-8") + b"\n\n")
207+
wfile.flush()
208+
except BrokenPipeError:
209+
client_alive = False
210+
trace("client.disconnected", request_id=request_id, message="client closed connection during stream")
202211

203212
def send_error(msg: str) -> None:
204213
send_event({"type": "response.error", "error": {"message": msg}})
205-
wfile.write(b"data: [DONE]\n\n")
206-
wfile.flush()
214+
if client_alive:
215+
wfile.write(b"data: [DONE]\n\n")
216+
wfile.flush()
207217

208218
try:
209219
api_key = resolve_api_key(config, request_id)
@@ -238,8 +248,26 @@ def send_error(msg: str) -> None:
238248
reasoning_open = False
239249
got_data = False
240250

251+
# Keepalive: send SSE comments every 15s while waiting for upstream first byte.
252+
# Prevents Codex from timing out when the model thinks for 30+ seconds before responding.
253+
keepalive_stop = threading.Event()
254+
255+
def keepalive() -> None:
256+
while not keepalive_stop.wait(15):
257+
if not client_alive:
258+
return
259+
try:
260+
wfile.write(b": keepalive\n\n")
261+
wfile.flush()
262+
except BrokenPipeError:
263+
return
264+
265+
ka_thread = threading.Thread(target=keepalive, daemon=True)
266+
ka_thread.start()
267+
241268
try:
242269
with urllib.request.urlopen(req, timeout=config.timeout_sec) as resp:
270+
keepalive_stop.set() # Stop keepalive once upstream starts responding.
243271
for line in resp:
244272
line = line.decode("utf-8", errors="replace").strip()
245273
if not line.startswith("data: "):
@@ -282,6 +310,12 @@ def send_error(msg: str) -> None:
282310
text += d
283311
send_event({"type": "response.output_text.delta", "item_id": message_id, "output_index": 1 if reasoning_open else 0, "delta": d})
284312
tcs = delta.get("tool_calls")
313+
if isinstance(tcs, list) and tcs and reasoning_open:
314+
# Close reasoning item before tool calls so UI shows tool calls, not "thinking".
315+
rs_done = {"type": "reasoning", "id": reasoning_id,
316+
"summary": [{"type": "summary_text", "text": reasoning}], "status": "completed"}
317+
send_event({"type": "response.output_item.done", "output_index": 0, "item": rs_done})
318+
reasoning_open = False
285319
if isinstance(tcs, list):
286320
for tc in tcs:
287321
idx = tc.get("index", 0)
@@ -317,6 +351,7 @@ def send_error(msg: str) -> None:
317351
if fn.get("arguments"):
318352
tool_calls[idx]["function"]["arguments"] += fn["arguments"]
319353
except urllib.error.HTTPError as exc:
354+
keepalive_stop.set()
320355
body = exc.read().decode("utf-8", errors="replace")
321356
trace("upstream.error", request_id=request_id, status=exc.code, body=body[:2000])
322357
if exc.code == 429:
@@ -328,6 +363,7 @@ def send_error(msg: str) -> None:
328363
send_error(f"upstream HTTP {exc.code}")
329364
return
330365
except (urllib.error.URLError, TimeoutError) as exc:
366+
keepalive_stop.set()
331367
trace("upstream.network_error", request_id=request_id, reason=str(getattr(exc, "reason", exc)))
332368
send_error(f"upstream network error: {getattr(exc, 'reason', exc)}")
333369
return
@@ -339,6 +375,10 @@ def send_error(msg: str) -> None:
339375
send_error("upstream returned no SSE data")
340376
return
341377

378+
if not client_alive:
379+
trace("client.gone", request_id=request_id, message="client disconnected before final events")
380+
return
381+
342382
# Build final response from accumulated data.
343383
fake_msg: Json = {}
344384
if reasoning:

0 commit comments

Comments
 (0)