Skip to content

Model switch mid-conversation fails: "encrypted content could not be decrypted" #17541

Open

Description

@caibirdme

What version of Codex CLI is running?

0.119.0

What subscription do you have?

azure

Which model were you using?

gpt-5.4-mini, gpt-5.4

What platform is your computer?

linux x64

What terminal emulator and version are you using (if applicable)?

No response

What issue are you seeing?

When switching models mid-conversation (e.g. via turn/start with a different model in app-server mode), the Responses API returns invalid_encrypted_content because encrypted_content from the previous model's Reasoning and Compaction items is sent verbatim to the new model, which cannot decrypt it.

What steps can reproduce the bug?

  1. Start a conversation using Model A (e.g. an Azure-hosted model that supports reasoning)
  2. Have a multi-turn conversation so that the history contains Reasoning items with encrypted_content
  3. Switch to Model B (e.g. gpt-5.4 on a different provider/deployment) mid-conversation via turn/start with the new model name
  4. Send a new user message in the same thread

What is the expected behavior?

The conversation should continue normally with Model B. Previous reasoning context should be handled gracefully (e.g. encrypted_content stripped, or summarized).

Additional information

Actual Behavior

The API returns an error:

litellm.ContentPolicyViolationError: AzureException - {
  "error": {
    "message": "The encrypted content gAAA...S6Q= could not be verified.
                Reason: Encrypted content could not be decrypted or parsed.",
    "type": "invalid_request_error",
    "param": null,
    "code": "invalid_encrypted_content"
  }
}

Root Cause Analysis

The encrypted_content field in ResponseItem::Reasoning and ResponseItem::Compaction is provider-specific encrypted data. It is designed so that the same model/provider can "recall" its reasoning without re-computing it. However, a different model/provider does not have the keys to decrypt it.

The issue is that the conversation history is sent to the new model without sanitizing these fields:

  1. History construction (core/src/codex.rs:6257-6261): sess.clone_history().await.for_prompt(...) returns all items including Reasoning with encrypted_content.

  2. for_prompt() / normalize_history() (core/src/context_manager/history.rs:120-125): Only strips images for text-only models and removes orphaned outputs. Does not strip encrypted_content.

  3. Request construction (core/src/client.rs:829-868): get_formatted_input() passes all history items (with encrypted_content) into the ResponsesApiRequest.input.

  4. The new model receives encrypted_content it cannot decrypt and returns an error.

Existing precedent for content stripping on model switch

The codebase already handles similar incompatibility when switching from an image-capable model to a text-only model — images are stripped and replaced with placeholder text (tested in model_change_from_image_to_text_strips_prior_image_content in core/tests/suite/model_switching.rs). The same pattern should apply to encrypted_content.

Affected Code Locations

File Location Description
codex-rs/protocol/src/models.rs:206-216 ResponseItem::Reasoning encrypted_content: Option<String> — provider-specific
codex-rs/protocol/src/models.rs:336-338 ResponseItem::Compaction encrypted_content: String — also provider-specific
codex-rs/core/src/context_manager/history.rs:120-125 for_prompt() Returns items without filtering encrypted_content
codex-rs/core/src/context_manager/history.rs:364-373 normalize_history() Does not handle encrypted_content
codex-rs/core/src/client.rs:829-868 build_responses_request() Includes all history items in input and adds include: ["reasoning.encrypted_content"]

Suggested Fix

When a model switch is detected (the new model slug differs from the model that produced the history items), strip encrypted_content from prior Reasoning and Compaction items before sending them to the new model. This could be done in one of:

  • normalize_history() in history.rs — similar to how images are stripped for text-only models
  • for_prompt() in history.rs — as an additional filtering step
  • build_responses_request() in client.rs — as a last-resort sanitization before the API call

The Reasoning item's summary field should be preserved so that the new model still has context about what the previous model was thinking.

Workaround

Create a new thread (thread/create) when switching models instead of switching within the same thread via turn/start. This avoids carrying over encrypted_content from the previous model.

Environment

  • Using Codex in app-server mode
  • Model switching via turn/start with a different model parameter
  • Backend: litellm proxy routing to Azure OpenAI deployments

Activity

  1. added
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    azureIssues related to the Azure-hosted OpenAI models
    on Apr 12, 2026
  2. github-actions commented on Apr 12, 2026

    @github-actions
    Contributor

    Potential duplicates detected. Please review them and close your issue if it is a duplicate.

    Powered by Codex Action

  3. removed
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    on Apr 12, 2026
  4. etraut-openai commented on Apr 12, 2026

    @etraut-openai
    Contributor

    If you haven't already done so, please report this issue to Azure.

  5. caibirdme commented on Apr 13, 2026

    @caibirdme
    Author

    This is related to but distinct from #15219, which reports the same invalid_encrypted_content error in a conversation resume scenario:

    #15219 (resume) This issue (mid-conversation switch)
    Trigger Resuming a saved conversation when the current profile uses a different provider Actively switching models within the same thread via turn/start
    Root cause merge_persisted_resume_metadata() fails to restore model_provider, so history is sent to the wrong endpoint History contains encrypted_content from Model A, which is sent to Model B that cannot decrypt it
    Fix direction Restore model_provider during resume so the original provider is used Strip encrypted_content from history when the model/provider changes

    #15219 can be fixed by routing back to the correct provider. This issue cannot — because the user intentionally switches to a different provider, so the old encrypted_content must be cleaned from the history rather than routed to the original provider.

  6. caibirdme commented on Apr 13, 2026

    @caibirdme
    Author

    If you haven't already done so, please report this issue to Azure.

    I think this might be an issue of codex itself. Because when I use opencode and copilot-cli(byok mode),I can switch among different models during the conversation without any problem. And I dug into opencode's source code, and found that they have dedicated logical to clear the encryped_content when model switch happens on gpt-* series model.

  7. safrano9999 commented on Jun 27, 2026

    @safrano9999

    I ran into this when moving an existing Codex session between Sakana Fugu and regular OpenAI GPT models. I published the small workaround I have been using here:

    https://github.com/safrano9999/codex-session-cleaner

    It removes only incompatible provider-specific encrypted reasoning records from the local rollout JSONL while preserving the visible conversation, tool records, session ID, file name, permissions, and timestamp. The same session can then be resumed with GPT instead of failing on encrypted content from the previous provider.

    In my setup this has worked very well for switching back and forth between Sakana Fugu and regular GPT when either provider reaches its rate limit. I run the cleaner before moving a Fugu-written session back to GPT:

    codex-session-cleaner last
    

    or pass the exact session UUID.

    This is an unofficial and deliberately narrow workaround: it discards hidden incompatible reasoning state rather than decrypting or translating it. Stop Codex for the affected session and back up the JSONL file before using it.

  8. ShawnFumo commented on Jul 16, 2026

    @ShawnFumo

    @safrano9999 thanks so much for making that and posting it! I'd switched from OpenRouter to Bedrock Mantle now that they just started supporting GPT-5.6 and then it stopped working suddenly and I tried to switch bad to OpenRouter and started getting the error. This took care of it and could continue with the session.

  9. chenty2333 commented on Jul 29, 2026

    @chenty2333

    I hit a closely related variant in Codex CLI 0.145.0-alpha.10 behind an OpenAI-compatible gateway:

    • Same Codex thread, same public provider configuration, and the same model (gpt-5.6-sol).
    • The rollout contained 1,824 Reasoning items with encrypted_content.
    • The failing request was about 0.86 MB. The last server-reported input was 177,270 / 258,400 tokens, so this was not a context-window overflow.
    • The gateway sticky upstream account A returned 502, then the gateway transparently failed over the exact same request to upstream account B.
    • Account B returned HTTP 400. Every subsequent retry of this thread on B returned 400, while other concurrent threads on B continued returning 200.
    • The gateway sanitized the upstream body into only: {"error":{"message":"Upstream request failed","type":"upstream_error"}}

    This suggests the encrypted replay state is account-bound even when the model and provider identity visible to Codex do not change. Detecting only an explicit model/provider switch cannot cover transparent upstream account failover.

    The immediate gateway-side mitigation is to preserve account stickiness for stateful threads (no cross-account failover), or discard incompatible encrypted reasoning/compaction state before retrying on another account. Codex could make the thread recoverable by recognizing the relevant 400 when details are available, offering to drop account/provider-bound replay-only items while preserving visible history, and surfacing a precise actionable error rather than leaving the thread permanently stuck.

    I am intentionally not attaching the raw rollout because it contains private prompts and tool outputs.

  10. Audacity88 commented on Sep 2, 2026

    @Audacity88

    A Desktop/paginated-history follow-on is worth noting for local recovery tools. Removing provider-bound encrypted reasoning shortens the source rollout. On paginated tasks, that can leave stale thread_history_* projections and descendant history_base.end_byte_offset values pointing at the wrong boundary, causing truncated UI history or invalid paginated history lineage after the original replay error is gone.

    A safer recovery preserves visible and tool records, stashes removed reasoning, rebases history offsets from ordinals transitively, resets only affected projections, invokes Codex's own rollout migration, and then re-audits lineage because migration can shift subsequent offsets.

    We packaged that backup-first workflow with synthetic regression coverage here: https://github.com/EyrieCommander/codex-skills/tree/master/skills/codex-history-repair

    Its scope is intentionally narrow: top-level encrypted reasoning records and paginated Desktop history. It does not claim to repair nested compactions, encrypted function/tool outputs, malformed JSONL, or duplicate ordinals. Codex and database watchers must be fully closed before mutation.

  11. OriyukiAkari commented on Sep 4, 2026

    @OriyukiAkari

    I reproduced this failure mode and implemented/tested a core-side approach against current Codex.

    Tested implementation:
    OriyukiAkari@926c1d1

    The main complication I found is that stripping encrypted_content solely on an explicit model-name change is not sufficient. The portability boundary also needs to account for provider changes, restart/resume, compaction, and histories where no previous real turn survives reconstruction.

    Approach

    The implementation treats provider-bound reasoning/compaction state as non-portable across a runtime boundary.

    When such a boundary is active, prior history is sanitized before it is sent to the new runtime:

    • Reasoning.encrypted_content is removed
    • plaintext reasoning summary/content is preserved
    • empty reasoning shells are dropped
    • prior Compaction / ContextCompaction items are dropped
    • user, assistant, and tool transcript items are preserved
    • current-turn reasoning is not modified

    This happens at request construction time; the persisted rollout JSONL and SQLite state are not rewritten.

    Provider provenance

    One edge case is a resumed history containing provider-bound opaque state but no surviving previous turn.

    Using only PreviousTurnSettings cannot detect that case because it is correctly None.

    The implementation therefore keeps loaded-history provider provenance separate from PreviousTurnSettings:

    1. use the newest surviving real turn's provider when available
    2. otherwise fall back to the loaded history / SessionMeta.model_provider
    3. otherwise treat provenance as unknown

    SessionMeta is not used to fabricate a previous model or previous turn.

    Lifecycle / restart behavior

    I initially considered inferring mixed runtime history by scanning the rollout globally, but that conflicts with reconstruction semantics such as completed-turn rollback, bounded replay after compaction, and unmatched/aborted turns.

    Instead, a sticky history-portability marker is persisted on surviving TurnContextItems and restored during reconstruction.

    The marker is intentionally conservative and remains active after the transition for now. It could potentially be cleared later after a verified compaction under the current runtime establishes a new portable checkpoint.

    Validation

    On the implementation above:

    • cargo check -p codex-core — PASS
    • cargo test -p codex-core rollout_reconstruction --lib — 38 passed
    • cargo test -p codex-core portability_boundary --lib — 3 passed
    • git diff HEAD^ HEAD --check — PASS

    I also tested this against a real legacy Desktop/app-server thread:

    • persisted provider: openai
    • resumed effective provider: custom
    • model: grok-4.6
    • history contained encrypted state produced under the previous runtime

    Before the change, the resumed request failed because the replacement provider received encrypted history it could not decrypt.

    After the change:

    1. cross-provider resume succeeded
    2. the first resumed sampling turn completed
    3. a second turn completed
    4. the app-server was fully stopped and restarted
    5. the same thread was resumed again
    6. post-restart sampling completed without an encrypted-content decryption failure

    The original provider-bound encrypted data remained in the rollout; it simply was not replayed to the incompatible runtime.

    I am posting the implementation as a tested reference rather than a PR because the current contribution guidelines state that external code contributions/PRs are not accepted. Hopefully the implementation and regression coverage are useful when addressing this in-tree.

  12. aaaaaandrew commented on Sep 11, 2026

    @aaaaaandrew

    Reproduced a closely related variant on Codex CLI 0.154.0 with a custom Responses provider backed by Amazon Bedrock Global CRIS.

    Sequence:

    1. Resume an existing thread whose previous turns used one reasoning model.
    2. Switch the resumed thread to another reasoning model.
    3. Send the first new turn.

    Codex replayed the prior reasoning.encrypted_content, and the upstream rejected it because Global CRIS had placed the new request in a different physical region:

    encrypted reasoning is scoped to the region that produced it and cannot be replayed in a different region. Start a new response in this region instead of threading reasoning from another region.
    

    This occurred without changing the logical Codex model provider or its global endpoint. The physical region selection is internal to CRIS and invisible to Codex, so restoring only the persisted provider cannot prevent it.

    This extends the portability boundary beyond explicit provider/account changes: model switches and transparent regional routing can also make prior encrypted reasoning non-portable. Codex should preserve the visible conversation/tool history while omitting stale provider-local reasoning/compaction state when the runtime changes, or recover from this specific pre-inference validation error by rebuilding the request without that opaque state.

  13. SidShaytay commented on Oct 10, 2026

    @SidShaytay

    I'm seeing this issue on pi 1.1.0. This is unresolved since ... April ? 6 months??

  14. virajchoudhary commented on Oct 14, 2026

    @virajchoudhary

    I reproduced the public history-forwarding defect against upstream 0960e379a5555f1b6d7b97d7980039902213c26c on Windows with Rust 1.95.0, isolated test homes, two localhost mock Responses providers, and synthetic credentials/content. No real account inference or existing conversation was used as a test target.

    The source mock returned a reasoning item containing synthetic encrypted_content, a readable summary, and a visible assistant answer. After persisting the rollout and resuming it with an explicitly selected target provider, the target mock rejected the forwarded source payload with HTTP 400 and error.code = invalid_encrypted_content. The baseline regression ran and failed on that exact rejection. Earlier stack-overflow and compiler-failed attempts were excluded from reproduction evidence.

    The defect is request-side history portability. It should be kept separate from whether any two actual models/providers share decryption keys. A mock rejection proves that Codex forwarded the foreign payload; it does not certify real encryption domains.

    Two constraints matter for a fix:

    • Filter reasoning ciphertext on an outbound copy while preserving readable summaries and stored rollout bytes.
    • Do not blanket-delete opaque compaction checkpoints. A checkpoint can be the only remaining representation of earlier context; unknown or incompatible provenance without complete plaintext reconstruction must refuse the request non-destructively.

    Independent review also identified a routing detail that a provider-name/configured-URL comparison misses. ModelProvider::responses_api_provider can resolve a different ChatGPT workspace backend and x-openai-account-routing-override. Provenance must reflect the effective request destination/scope, not just provider.info().base_url. Configuration equality is not a cryptographic compatibility guarantee, and credential/account changes or hidden router backends remain separate concerns.

    A local reference implementation has passing custom-provider/model-switching and storage-preservation regressions, but final request-route acceptance is still in progress. I am not publishing it as a verified fix yet. No upstream PR has been opened, and no installed Codex, production provider/auth configuration, or stored conversation has been modified.

    The Desktop side-chat routing report in #46379 and reserve/continuation quota reports in #43890 and #29717 are not established as fixed by this reproduction.

  15. SidShaytay commented on Oct 15, 2026

    @SidShaytay

    If anyone is running into this within the pi harness, I've make an extension for it at https://www.npmjs.com/package/@sidshaytay/heal-codex-session

    The healing part is rather simple, it just drops the encrypted reasoning blob from the transcript. You'd probably have an input cache miss at that point but at least the session continues. The chat result, tool call output etc stays - only loss is the thinking tokens from earlier round, so recovery is pretty good.

    For me, most long agentic sessions would eventually delay into this situation.

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

    azureIssues related to the Azure-hosted OpenAI modelsbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions