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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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