openai / openai/codex

Reasoning items are replayed with an explicit "encrypted_content": null, inconsistently with the sibling ContextCompaction variant (schema-legal; see correction below)

Open Beginner friendly
#42,368 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug CLI custom-model
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

Summary

When a provider returns a reasoning item that has no encrypted_content field, Codex stores it and replays it on the following turn with "encrypted_content": null explicitly present. The Responses schema does not permit a null there, so a provider that validates strictly against the schema will reject the turn. The item is otherwise well-formed — a real id, a populated summary[].summary_text — so the request is rejected purely on a field Codex added itself.

The cause looks like a missing serde attribute rather than logic: ResponseItem::Reasoning's encrypted_content is the only optional field in that variant without skip_serializing_if, and the outbound hygiene pass that fixes up replayed items never looks at it.

Environment
  • codex-cli 0.152.1 (Homebrew cask)
  • macOS 15.8.0, arm64
  • Custom model_provider with wire_api = "responses", requires_openai_auth = false, supports_websockets = false
Steps to reproduce

Two files. No network access needed; the mock provider is 90 lines of stdlib Python.

1. mock_responses.py — a /v1/responses endpoint that, on turn 1, emits a reasoning item without any encrypted_content key, plus one function_call so there is a turn 2. It logs the input array of every request it receives.

#!/usr/bin/env python3
"""Minimal mock /v1/responses reproducing the null-encrypted_content echo-back."""
import json
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

LOG = "received.jsonl"
STATE = {"turn": 0}


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *args):
        pass

    def do_GET(self):  # /v1/models probe
        body = b'{"object":"list","data":[]}'
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        n = int(self.headers.get("Content-Length") or 0)
        body = json.loads(self.rfile.read(n))
        STATE["turn"] += 1
        turn = STATE["turn"]

        with open(LOG, "a") as fh:
            fh.write(json.dumps({"turn": turn, "input": body.get("input", [])}) + "\n")

        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()

        def emit(obj):
            self.wfile.write(f"event: {obj['type']}\ndata: {json.dumps(obj)}\n\n".encode())
            self.wfile.flush()

        response = {"id": f"resp_mock_{turn}", "object": "response",
                    "status": "in_progress", "model": body.get("model"), "output": []}
        emit({"type": "response.created", "response": response})

        items = []
        if turn == 1:
            # NOTE: no `encrypted_content` key at all.
            reasoning = {"type": "reasoning", "id": "rs_mock_1",
                         "summary": [{"type": "summary_text", "text": "thinking about it"}],
                         "content": []}
            items.append(reasoning)
            emit({"type": "response.output_item.done", "output_index": 0, "item": reasoning})

            call = {"type": "function_call", "id": "fc_0", "call_id": "call_0",
                    "name": "exec_command",
                    "arguments": json.dumps({"cmd": ["/bin/echo", "hello"]}),
                    "status": "completed"}
            items.append(call)
            emit({"type": "response.output_item.done", "output_index": 1, "item": call})
        else:
            message = {"type": "message", "id": f"msg_{turn}", "role": "assistant",
                       "status": "completed",
                       "content": [{"type": "output_text", "text": "DONE", "annotations": []}]}
            items.append(message)
            emit({"type": "response.output_item.done", "output_index": 0, "item": message})

        done = dict(response)
        done["status"] = "completed"
        done["output"] = items
        done["usage"] = {"input_tokens": 100, "input_tokens_details": {"cached_tokens": 0},
                         "output_tokens": 10,
                         "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 110}
        emit({"type": "response.completed", "response": done})


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()

2. home/config.toml — an isolated CODEX_HOME pointing at the mock:

model           = "gpt-5.6"
model_provider  = "mock"
approval_policy = "never"
sandbox_mode    = "danger-full-access"

[model_providers.mock]
name                 = "mock"
base_url             = "http://127.0.0.1:8899/v1"
env_key              = "MOCK_API_KEY"
wire_api             = "responses"
requires_openai_auth = false
supports_websockets  = false

3. Run:

mkdir -p home && cp config.toml home/config.toml
python3 mock_responses.py 8899 &
echo "run /bin/echo hello then say DONE" \
  | CODEX_HOME="$PWD/home" MOCK_API_KEY=dummy codex exec \
      --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox

python3 - <<'PY'
import json
for line in open("received.jsonl"):
    rec = json.loads(line)
    for item in rec["input"]:
        if item.get("type") == "reasoning":
            print("turn", rec["turn"], json.dumps(item))
PY
Actual result

Turn 1's response carried a reasoning item with no encrypted_content key. Turn 2's request carries it back with an explicit null:

{
  "type": "reasoning",
  "id": "rs_mock_1",
  "summary": [{ "type": "summary_text", "text": "thinking about it" }],
  "encrypted_content": null
}

Against a provider that validates strictly against the schema this turn would fail; against the mock it simply demonstrates the shape. Stated plainly: I have not captured such a rejection first-hand, so the impact claim rests on the schema itself and on the independent observation quoted in #36704 below, not on an error I reproduced. The in-repo inconsistency in the next section stands regardless.

Expected result

Either omit the field when there is nothing to send:

{
  "type": "reasoning",
  "id": "rs_mock_1",
  "summary": [{ "type": "summary_text", "text": "thinking about it" }]
}

…or strip a null encrypted_content in the outbound pass before the request is serialized. Omission seems clearly preferable — it matches what the provider originally sent, and it is what the rest of the enum already does.

Where it comes from

Two places, both at tag rust-v0.152.1 (commit 5adb68a49933ae446bf11935662c83dba55a0804).

1. ResponseItem::Reasoning.encrypted_content has no skip_serializing_ifcodex-rs/protocol/src/models.rs:1011-1023. It is the only optional field in the variant without one, so None serializes as null:

    Reasoning {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        #[ts(optional)]
        id: Option<ResponseItemId>,
        summary: Vec<ReasoningItemReasoningSummary>,
        #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
        #[ts(optional)]
        content: Option<Vec<ReasoningItemContent>>,
        encrypted_content: Option<String>,          // <-- no skip_serializing_if
        #[serde(default, skip_serializing_if = "Option::is_none")]
        #[ts(optional)]
        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
    },

The same crate already does the right thing for the same field name on a sibling variant — ResponseItem::ContextCompaction, models.rs:1202-1212:

    ContextCompaction {
        #[serde(default, skip_serializing_if = "Option::is_none")]
        #[ts(optional)]
        id: Option<ResponseItemId>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        #[ts(optional)]
        encrypted_content: Option<String>,          // <-- has it
        ...
    },

So this reads as an oversight rather than an intentional difference, and the fix is one attribute.

2. The outbound hygiene pass does not compensateModelClient::prepare_response_items_for_request(), codex-rs/core/src/client.rs:1035-1044. It is the function that normalizes replayed items before a request, and it only touches id and content-item kinds:

    fn prepare_response_items_for_request(&self, input: &mut [ResponseItem]) {
        for item in input {
            if item.id().is_some_and(|id| !id.is_prefixed()) {
                item.set_id(/*new_id*/ None);
            }
            if !self.state.content_item_kinds_enabled {
                item.clear_content_item_kinds();
            }
        }
    }

It is called from all four request paths (client.rs:663, :1637, :1850, :1859), so it is the natural place for a belt-and-braces strip if you would rather not change the serde attribute.

Reproduce these two quotes:

SHA=5adb68a49933ae446bf11935662c83dba55a0804
curl -sL "https://raw.githubusercontent.com/openai/codex/$SHA/codex-rs/protocol/src/models.rs" | sed -n '1011,1023p;1202,1212p'
curl -sL "https://raw.githubusercontent.com/openai/codex/$SHA/codex-rs/core/src/client.rs" | sed -n '1035,1044p'
Suggested fix

Add #[serde(default, skip_serializing_if = "Option::is_none")] to ResponseItem::Reasoning.encrypted_content in codex-rs/protocol/src/models.rs, matching ContextCompaction twelve variants below. If a defensive strip is also wanted, prepare_response_items_for_request is the place.

Related but distinct
  • #42249 — DeepSeek + wire_api = "responses", intermittent 400 The `reasoning_text` in the thinking mode must be passed back to the API. There, encrypted_content is present as a 38-byte stub with an empty summary and the payload in content[], and the rejection is DeepSeek's plaintext-replay contract. Here it is explicitly null, with a populated summary and no content, and the rejection is a schema violation. Same serialization layer, different malformed shape, different rejection reason.
  • #36704 — key-rotation recovery; contains the only other mention of this shape in the tracker, as a side observation: "The 4 earliest reasoning items […] had encrypted_content: null explicitly present alongside content: [reasoning_text] (length 1) — also a schema problem, since the Responses API enforces content: [] (maxItems=0) when the encrypted_content field is present, even if null." That issue's subject and ask are stale ciphertext after key rotation, not echo-back, but the quote independently supports the claim that the null shape is schema-invalid.
  • #38855 — type-invalid item_ reasoning ids surviving replay validation. Same function, prepare_response_items_for_request, same layer; validates id but not encrypted_content.
  • #24500 — the chat-completions-path sibling (reasoning_content not serialized back for DeepSeek thinking mode).
Evidence against

Searched the openai/codex tracker (issues and PRs, open and closed) for encrypted_content, encrypted_content null, prepare_response_items_for_request, reasoning_text must be passed back, reasoning item, and several phrasings of the gateway-rejection symptom; pulled and grepped the full bodies of 31 candidate issues. Found no source contradicting the claim, and no prior report of this specific behavior. The nearest counter-consideration is #36704: it shows a maintainer-visible mention of encrypted_content: null has existed since 2026-08-03 without being fixed, so it is possible the shape is considered acceptable by the first-party endpoint and the problem is deemed to be the strict gateway's. That is why this report leads with the in-repo inconsistency (Reasoning vs ContextCompaction) rather than with "our gateway rejects it" — the inconsistency stands on its own regardless of how tolerant the first-party endpoint is.

Verification

prepare_response_items_for_request has no test asserting anything about encrypted_content; a regression test would serialize a ResponseItem::Reasoning with encrypted_content: None and assert the key is absent from the JSON.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in codex-rs/protocol/src/models.rs around ResponseItem::Reasoning.encrypted_content and compare it with the ContextCompaction variant. Check the related prepare_response_items_for_request entry point in codex-rs/core/src/client.rs, then verify serialized reasoning items omit encrypted_content when it is absent; done means the replayed request matches the provider's schema.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api
Issue type
Bug
Difficulty
1/5
Estimated time
Under an hour
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
88/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.