Skip to content

Codex plugin returns HTTP 502 on every /codex/v1/chat/completions: ResponseObject.output missing after SSE reassembly (affects all 0.2.x) #55

@srgvg

Description

@srgvg

Bug Description

Every /codex/v1/chat/completions request with a streaming Codex upstream response returns HTTP 502 with a ResponseObject.output Field required [type=missing] Pydantic validation error, even though the upstream call itself succeeds (HTTP 200, full SSE stream, non-zero output_tokens).

Reproducible in 0.2.6 and 0.2.7. Code inspection of the relevant tags shows the SSE reassembly code has been unchanged since 5e41a2d (2025-09-28), so this affects every 0.2.x release.

First observed 2026-04-09 after months of stable operation, which suggests the ChatGPT Codex backend (https://chatgpt.com/backend-api/codex/responses) recently changed its SSE event shape in a way the current ResponsesAccumulator does not handle.

Error

{
  "error": {
    "type": "server_error",
    "message": "Failed to convert provider response using format chain",
    "details": "1 validation error for ResponseObject\noutput\n  Field required [type=missing, input_value={'id': 'resp_...', 'object': 'response', ..., 'top_logprobs': 0}, input_type=dict]\n    For further information visit https://errors.pydantic.dev/2.12/v/missing"
  }
}

Structlog traceback in ccproxy:

codex_format_chain_response_failed
  File "ccproxy/plugins/codex/adapter.py", line 202, in handle_request
    response_payload = await self._apply_format_chain(...)
  File "ccproxy/services/adapters/http_adapter.py", line 941, in _apply_format_chain
    current = await adapter.convert_response(current)
  File "ccproxy/services/adapters/simple_converters.py", line 348, in convert_openai_responses_to_openai_chat_response
    response = openai_models.ResponseObject.model_validate(data)
ValidationError: 1 validation error for ResponseObject
output
  Field required [type=missing, ...]

Upstream call is healthy

Structured log line from the same request (request_id 5d610c11):

sse_connection_ended  closed_by=server  duration_ms=38151  success=True  status_code=200
  url=https://chatgpt.com/backend-api/codex/responses
streaming_buffer_upstream_response  status_code=200  total_bytes=315959  total_chunks=431
streaming_metrics_finalized  usage_metrics={'input_tokens': 22800, 'output_tokens': 1954,
                                            'cache_read_input_tokens': 2560, 'reasoning_tokens': 1572}

So Codex returned 431 SSE chunks totaling 315 KB with 1 954 output tokens — the model did produce output. The failure is purely in ccproxy's reassembly of that stream into a ResponseObject dict.

Reassembled body preview

streaming_buffer_response_ready logs the body ccproxy builds from the stream:

{
  "id": "resp_070491e4e19538ee0169d8eee6e49481919773ebfb95e31586",
  "object": "response",
  "created_at": 1775824614,
  "status": "completed",
  "model": "gpt-5.3-codex",
  "parallel_tool_calls": true,
  "usage": {
    "input_tokens": 22800,
    "input_tokens_details": {"cached_tokens": 2560},
    "output_tokens": 1954,
    "output_tokens_details": {"reasoning_tokens": 1572},
    "total_tokens": 24754
  },
  "instructions": "You are Codex, a coding agent based on GPT-5. ..."
}

No output field anywhere. The Pydantic input_value preview additionally shows 'top_logprobs': 0 at the tail of the dict — a key that's not in ccproxy/llms/models/openai.py::ResponseObject, suggesting extra fields from a response.created / response.in_progress payload leaked through while the actual output items (emitted separately via response.output_item.added / .done and text-delta events) were never collected into the final dict.

Root cause (code inspection)

Two independent defects in ccproxy/streaming/buffer.py::_parse_collected_stream (lines 502–655 in 0.2.6/0.2.7):

1. output fallback uses the wrong type

# ccproxy/streaming/buffer.py, ~line 587
response_obj.setdefault("output", response_obj.get("output") or {})

The fallback value is {} (empty dict) but ResponseObject.output is typed as list[MessageOutput | ReasoningOutput | FunctionCallOutput | dict[str, Any]]. Even when this fallback runs, the subsequent ResponseObject.model_validate() fails because {} is not a list.

Should be [].

2. ResponsesAccumulator.rebuild_response_object fallback path does not populate output

When _parse_collected_stream calls:

completed_payload = accumulator_for_rebuild.get_completed_response()
if completed_payload is not None:
    response_obj = completed_payload
    return response_obj
try:
    response_obj = accumulator_for_rebuild.rebuild_response_object(response_obj)
    ...
except Exception as exc:
    logger.debug("response_rebuild_failed", error=str(exc), ...)

get_completed_response() returns None (so self.completed_response was never set by a typed ResponseCompletedEvent), and rebuild_response_object() silently fails inside the try/except — the debug log is discarded at --log-level info so there is no visible trace. response_obj then retains its pre-rebuild state, which is missing output, and gets returned to the caller and handed to the format chain → validation error.

Most likely the upstream response.completed event shape has changed and no longer validates against openai_models.ResponseCompletedEvent / ResponseObject, causing _coerce_stream_event inside ResponsesAccumulator.accumulate to silently drop it (wrapped in contextlib.suppress(Exception) at buffer.py:563).

Affected versions

Confirmed by wheel comparison:

  • ccproxy/streaming/buffer.py and ccproxy/llms/streaming/accumulators.py are byte-for-byte identical across v0.2.0 → v0.2.7. Both frozen at commit 5e41a2d (2025-09-28).
  • So every 0.2.x release has this bug once the upstream SSE shape triggers the fallback path.

Reproduction

# In a ccproxy 0.2.7 pod with Codex OAuth credentials:
curl -sS http://localhost:8000/codex/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{
    "model": "gpt-5-codex",
    "stream": false,
    "messages": [
      {"role": "user", "content": "<any prompt that produces a few thousand output tokens>"}
    ]
  }'

Result: HTTP 502 with the error above.

The easiest way to see the raw upstream payload is to run ccproxy with --log-level debug and grep for streaming_buffer_collected_content and streaming_buffer_accumulator_rebuild_attempt around the failing request.

Suggested fixes

  1. Immediate: change or {} to or [] in ccproxy/streaming/buffer.py:587. This is obviously wrong today regardless of the upstream shape.
  2. Real fix: capture an up-to-date sample of the chatgpt.com/backend-api/codex/responses SSE stream (example request IDs from my logs: 520cbb5a, 5d610c11, 90b829eb) and update ResponsesAccumulator._coerce_stream_event / openai_models.*Event to validate against the current event schema. The contextlib.suppress(Exception) at buffer.py:563 and the try/except at buffer.py:635 are currently hiding the coercion failures — logging those at warning would make future drift visible.
  3. Defensive: if rebuild_response_object fails, the buffer could fall back to regenerating a minimal ResponseObject from the aggregated text_content / usage it already tracks, instead of returning a dict that the next stage can't possibly validate.

Environment

  • ccproxy-api: 0.2.6 (originally hit on 0.2.7; downgrading to 0.2.6 did not change behaviour because the reassembly code is identical)
  • Python: 3.14
  • OS: Linux (python:3.14-slim container on Talos Kubernetes)
  • Upstream: https://chatgpt.com/backend-api/codex/responses via OAuth (@openai/codex 0.118.0 CLI for token management)
  • Client: a GitLab-CI mr-review job POSTing chat-completions-shaped requests with streaming disabled
  • ccproxy command: ccproxy serve --port 8000 --host 0.0.0.0 --log-level info --enable-plugin codex --enable-plugin access_log --disable-plugin claude_sdk --disable-plugin claude_api --disable-plugin copilot

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions