Fix Codex account SSE response recovery
Decode byte chunks from the live Codex/ChatGPT account SSE stream before parsing events. Preserve accumulated output_text deltas when the final response.completed object is present but has no extractable output content. Update the OAuth tests to cover byte-delivered SSE chunks and empty completed responses.
Alessandro committed
Apr 28, 2026 at 15:45 UTC
d387b1827fdc98d3bc579fc1ecc33a37570a11d5
2 files changed
+19
-6
plugins/_oauth/helpers/codex.py
+12
-2
@@ -450,13 +450,19 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
450
if isinstance(candidate, dict):
451
latest_response = candidate
452
453
- if latest_response is not None:
454
- return latest_response
453
if text_pieces:
454
+ text = "".join(text_pieces)
455
+ if latest_response is not None:
456
+ completed = dict(latest_response)
457
+ if not response_text(completed):
458
+ completed["output_text"] = text
459
+ return completed
460
result: dict[str, Any] = {"output_text": "".join(text_pieces)}
461
if latest_usage:
462
result["usage"] = latest_usage
463
return result
464
+ if latest_response is not None:
465
+ return latest_response
466
suffix = f" Last error: {json.dumps(latest_error)}" if latest_error else ""
467
raise RuntimeError(f"No completed response found in Codex SSE stream.{suffix}")
468
@@ -466,6 +472,8 @@ def iter_sse_events(response: requests.Response) -> Iterable[dict[str, str]]:
472
for chunk in response.iter_content(chunk_size=8192, decode_unicode=True):
473
if not chunk:
474
continue
475
+ if isinstance(chunk, bytes):
476
+ chunk = chunk.decode(response.encoding or "utf-8", errors="replace")
477
buffer += chunk
478
while "\n\n" in buffer or "\r\n\r\n" in buffer:
479
sep = "\r\n\r\n" if "\r\n\r\n" in buffer else "\n\n"
@@ -518,7 +526,9 @@ def extract_sse_text_deltas(payload: dict[str, Any], event_type: str = "") -> li
526
527
if (payload.get("type") or event_type) in {
528
"response.output_text.delta",
529
+ "response.output_text.done",
530
"response.text.delta",
531
+ "response.text.done",
532
}:
533
_append_text_value(pieces, payload.get("text"))
534
tests/test_oauth_codex.py
+7
-4
@@ -120,13 +120,16 @@ def test_extract_sse_text_deltas_reads_chat_completion_chunks():
120
121
def test_collect_completed_response_falls_back_to_text_deltas():
122
class FakeResponse:
123
+ encoding = "utf-8"
124
+
125
def iter_content(self, chunk_size=8192, decode_unicode=True):
126
del chunk_size, decode_unicode
125
- yield 'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
126
- yield 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
127
- yield "data: [DONE]\n\n"
127
+ yield b'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
128
+ yield b'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
129
+ yield b'event: response.completed\ndata: {"response":{"output":[]}}\n\n'
130
+ yield b"data: [DONE]\n\n"
131
129
- assert codex.collect_completed_response(FakeResponse()) == {"output_text": "Hello"}
132
+ assert codex.collect_completed_response(FakeResponse()) == {"output": [], "output_text": "Hello"}
133
134
135
def test_provider_config_uses_container_local_agent_zero_origin():