Repository navigation
Model switch mid-conversation fails: "encrypted content could not be decrypted" #17541
Description
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?
- Start a conversation using Model A (e.g. an Azure-hosted model that supports reasoning)
- Have a multi-turn conversation so that the history contains
Reasoningitems withencrypted_content - Switch to Model B (e.g.
gpt-5.4on a different provider/deployment) mid-conversation viaturn/startwith the new model name - 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:
-
History construction (
core/src/codex.rs:6257-6261):sess.clone_history().await.for_prompt(...)returns all items includingReasoningwithencrypted_content. -
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 stripencrypted_content. -
Request construction (
core/src/client.rs:829-868):get_formatted_input()passes all history items (withencrypted_content) into theResponsesApiRequest.input. -
The new model receives
encrypted_contentit 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()inhistory.rs— similar to how images are stripped for text-only modelsfor_prompt()inhistory.rs— as an additional filtering stepbuild_responses_request()inclient.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/startwith a differentmodelparameter - Backend: litellm proxy routing to Azure OpenAI deployments
Activity
github-actions commentedon Apr 12, 2026
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
etraut-openai commentedon Apr 12, 2026
If you haven't already done so, please report this issue to Azure.
caibirdme commentedon Apr 13, 2026
This is related to but distinct from #15219, which reports the same
invalid_encrypted_contenterror in a conversation resume scenario:turn/startmerge_persisted_resume_metadata()fails to restoremodel_provider, so history is sent to the wrong endpointencrypted_contentfrom Model A, which is sent to Model B that cannot decrypt itmodel_providerduring resume so the original provider is usedencrypted_contentfrom 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_contentmust be cleaned from the history rather than routed to the original provider.caibirdme commentedon Apr 13, 2026
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.
docs(adr): fold 2026-06-17 spike findings into ADR 006
safrano9999 commentedon Jun 27, 2026
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:
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.
ShawnFumo commentedon Jul 16, 2026
@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.
chenty2333 commentedon Jul 29, 2026
I hit a closely related variant in Codex CLI 0.145.0-alpha.10 behind an OpenAI-compatible gateway:
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.
Audacity88 commentedon Sep 2, 2026
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 descendanthistory_base.end_byte_offsetvalues pointing at the wrong boundary, causing truncated UI history orinvalid paginated history lineageafter 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.
OriyukiAkari commentedon Sep 4, 2026
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_contentsolely 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_contentis removedCompaction/ContextCompactionitems are droppedThis 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
PreviousTurnSettingscannot detect that case because it is correctlyNone.The implementation therefore keeps loaded-history provider provenance separate from
PreviousTurnSettings:SessionMeta.model_providerSessionMetais 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— PASScargo test -p codex-core rollout_reconstruction --lib— 38 passedcargo test -p codex-core portability_boundary --lib— 3 passedgit diff HEAD^ HEAD --check— PASSI also tested this against a real legacy Desktop/app-server thread:
openaicustomgrok-4.6Before the change, the resumed request failed because the replacement provider received encrypted history it could not decrypt.
After the change:
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.
aaaaaandrew commentedon Sep 11, 2026
Reproduced a closely related variant on Codex CLI 0.154.0 with a custom Responses provider backed by Amazon Bedrock Global CRIS.
Sequence:
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: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.
SidShaytay commentedon Oct 10, 2026
I'm seeing this issue on pi 1.1.0. This is unresolved since ... April ? 6 months??
virajchoudhary commentedon Oct 14, 2026
I reproduced the public history-forwarding defect against upstream
0960e379a5555f1b6d7b97d7980039902213c26con 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 anderror.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:
Independent review also identified a routing detail that a provider-name/configured-URL comparison misses.
ModelProvider::responses_api_providercan resolve a different ChatGPT workspace backend andx-openai-account-routing-override. Provenance must reflect the effective request destination/scope, not justprovider.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.
SidShaytay commentedon Oct 15, 2026
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.