Fix Codex OAuth utility responses
Preserve completed SSE output items when the final Codex response envelope omits them so non-streaming utility calls retain their generated text. Add focused regression coverage for the native output-item path.
Alessandro committed
Aug 19, 2026 at 04:23 UTC
f6dd6128d9ad4ff27d8d3beb346ce1e5386efcd6
3 files changed
+53
plugins/_oauth/AGENTS.md
+1
@@ -47,6 +47,7 @@
47
- Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence.
48
- Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence.
49
- Codex request shaping tightens an already-advertised native `response` tool to a strict required `text` schema; it must not add tools omitted by the framework tool policy.
50
+- Non-streaming Codex proxy responses must retain completed SSE output items when the final `response.completed` envelope omits them.
51
- OAuth providers without upstream Responses support must set `a0_api_mode: chat`; native Responses providers rely on the default, since a local proxy route alone does not prove upstream support.
52
53
## Work Guidance
plugins/_oauth/helpers/codex.py
+16
@@ -794,6 +794,7 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
794
latest_error: Any = None
795
text_pieces: list[str] = []
796
latest_usage: dict[str, Any] | None = None
797
+ completed_items: dict[int, dict[str, Any]] = {}
798
for event in iter_sse_events(response):
799
data = event.get("data")
800
if not data:
@@ -808,6 +809,11 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
809
latest_error = parsed
810
continue
811
text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
812
+ if (parsed.get("type") or event.get("event")) == "response.output_item.done":
813
+ output_index = parsed.get("output_index")
814
+ item = parsed.get("item")
815
+ if isinstance(output_index, int) and isinstance(item, dict):
816
+ completed_items[output_index] = item
817
usage = parsed.get("usage")
818
if isinstance(usage, dict):
819
latest_usage = usage
@@ -815,6 +821,16 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
821
if isinstance(candidate, dict):
822
latest_response = candidate
823
824
+ if (
825
+ latest_response is not None
826
+ and completed_items
827
+ and not latest_response.get("output")
828
+ ):
829
+ latest_response = dict(latest_response)
830
+ latest_response["output"] = [
831
+ completed_items[index] for index in sorted(completed_items)
832
+ ]
833
+
834
if text_pieces:
835
text = "".join(text_pieces)
836
if latest_response is not None:
tests/test_oauth_codex.py
+36
@@ -521,6 +521,42 @@ def test_extract_sse_text_deltas_ignores_final_done_text():
521
)
522
523
524
+def test_collect_completed_response_restores_native_output_items():
525
+ item = {
526
+ "id": "msg_1",
527
+ "type": "message",
528
+ "status": "completed",
529
+ "content": [
530
+ {
531
+ "type": "output_text",
532
+ "annotations": [],
533
+ "logprobs": [],
534
+ "text": "Hello",
535
+ }
536
+ ],
537
+ "role": "assistant",
538
+ }
539
+
540
+ class FakeResponse:
541
+ encoding = "utf-8"
542
+
543
+ def iter_content(self, chunk_size=8192, decode_unicode=True):
544
+ del chunk_size, decode_unicode
545
+ yield (
546
+ 'data: {"type":"response.output_item.done","output_index":0,'
547
+ f'"item":{json.dumps(item)}}}\n\n'
548
+ ).encode()
549
+ yield (
550
+ b'data: {"type":"response.completed",'
551
+ b'"response":{"id":"resp_1","output":[]}}\n\n'
552
+ )
553
+
554
+ assert codex.collect_completed_response(FakeResponse()) == {
555
+ "id": "resp_1",
556
+ "output": [item],
557
+ }
558
+
559
+
560
def test_collect_completed_response_falls_back_to_text_deltas():
561
class FakeResponse:
562
encoding = "utf-8"