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():