Skip to content

fix(responses): strip cross-backend reasoning state on a model switch - #2248

Closed
olddonkey wants to merge 2 commits into
lidge-jun:devfrom
olddonkey:fix/cross-backend-reasoning-replay
Closed

fix(responses): strip cross-backend reasoning state on a model switch#2248
olddonkey wants to merge 2 commits into
lidge-jun:devfrom
olddonkey:fix/cross-backend-reasoning-replay

Conversation

@olddonkey

@olddonkey olddonkey commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Switching model mid-conversation breaks the next turn. Both failures are cross-backend: the client replays history minted by the previous destination, and the new one rejects it.

Reproduced against live providers through the proxy — mint a reasoning item on xai/grok-4.6, then replay that history to openai/gpt-5.6-sol:

replay grok -> grok : OK
replay grok -> SOL  : FAILED  Unknown parameter: 'input[1].status'
  ... with `status` removed:
replay grok -> SOL  : FAILED  The encrypted content ZvQ+...fBJg could not be verified.
                              Reason: Encrypted content could not be decrypted or parsed.
  ... with `status` and `encrypted_content` removed:
replay grok -> SOL  : OK

Two independent problems, in that order.

(a) status on a replayed reasoning item. Grok emits status: "completed"; OpenAI rejects the field on input. It is output-only — Grok itself still accepts a replayed item without it, so removing it is safe in both directions.

(b) A reasoning blob is only decodable by the backend that minted it. Same class as the compaction-blob provenance problem in #2228, with encrypted_content on reasoning items instead.

Approach — extend the existing mechanism, not a new one

src/responses/reasoning-replay-cache.ts already implements a bounded, thread-scoped store (64 entries / 256 KiB / 1 h TTL) and already computes the identity tuple bindRouteReasoningReplayScope uses. This adds a small parallel record with the same discipline: remember, per client thread, the identity that served the most recent request; on the next request for that thread, if the identity differs, strip encrypted_content from replayed reasoning items before the request goes out.

No retry-on-4xx. Deterministic pre-flight only.

The record compares rotation-safe dimensions

The identity tuple includes the credential's OAuth generation, which changes on every token refresh — and six of the eight bindRouteReasoningReplayScope call sites are rotation or refresh rebinds. Comparing that tuple would make every key-pool provider look like a destination switch on refresh. The serving record therefore compares only durable dimensions (providerDestinationDurableIdentity, credentialDurableIdentity) and refuses to record when either is missing, rather than falling back to volatile ones. The proxy's own cache keeps its strict key unchanged.

The failure directions are deliberately asymmetric: a missed strip costs one degraded turn; a spurious strip is a permanent quality regression.

What is deliberately not covered

No record — fresh process, evicted, expired, or no client thread id — keeps the blobs. Stripping on "unknown" would discard valid cached reasoning after every restart. This is stated in a comment rather than papered over, and it is real: a switch that spans a proxy restart still fails. Closing it needs the upstream's own error signal and is a separate change.

Interaction with other model routing, documented not changed

  • Combo rotation legitimately drops blobs, and the SSE model-name rewrite hides the switch from the client, so the user sees reasoning quality change with no visible cause.
  • The image and web-search sidecar loops consume the reasoning scope without rebinding it. That is what keeps internal small-model calls from poisoning the thread's record — worth stating explicitly, since "helpfully" adding a bind there would break it.

Prompts carrying images are unaffected: these transforms touch tool containers, reasoning items and compaction items, never message content.

Verification

Live, through the deployed proxy, with real blob-bearing items on both routes (blobs 468–3128 chars):

check result
grok replay, own blob + content: null 200
openai replay, own blob intact 200
grok history -> SOL, same thread 200
SOL history -> grok, same thread 200

Counterfactual, to show the strip is what does the work rather than the upstream having become lenient — the same grok item (726-char blob, status present) replayed to gpt-5.6-sol:

thread record result
same thread id exists -> strip fires 200
fresh thread id none -> blob kept by design 400

That second row is the documented restart gap behaving exactly as designed.

Tests

bun run test — 13742 pass, 10 skip, 1 fail: tests/key-login-live-update.test.ts ("notify after key login pushes the merged row and keeps modelCosts"). That failure is pre-existing and unrelated — it reproduces identically on branches that never touch CLI code, and appears on every branch in this series.

Part of #2240.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of encrypted reasoning data during response passthrough.
    • Preserves encrypted reasoning content when routing remains unchanged.
    • Removes stale encrypted content after a confirmed route change.
    • Cleans up reasoning metadata when encrypted content is unavailable.
    • Maintains consistent behavior during credential, model, and destination changes.
  • Tests

    • Added coverage for reasoning-content preservation, cleanup, route changes, expiration, and bounded replay history.

olddonkey and others added 2 commits August 20, 2026 19:05
…route switch

Switching models mid-conversation broke the next turn. Reproduced end to end
through the proxy: mint a reasoning item on xai/grok-4.6, replay to
openai/gpt-5.6-sol.

  replay grok -> grok : OK
  replay grok -> SOL  : Unknown parameter: 'input[1].status'
    ... status removed:
  replay grok -> SOL  : The encrypted content ZvQ+...fBJg could not be verified.
    ... status and encrypted_content removed:
  replay grok -> SOL  : OK

Two independent problems. Grok emits an output-only `status` on reasoning items
that OpenAI rejects on input, and a reasoning blob is decodable only by the
backend that minted it, so after a switch the client replays blobs the new
destination cannot read.

This extends the mechanism the repo already uses for opaque provider state
rather than adding a retry: `reasoning-replay-cache` already keeps a bounded,
thread-scoped store and already computes the provider/destination/adapter/model/
credential identity. It now also records which identity served a thread last, and
a request whose identity differs from that record drops `encrypted_content` from
replayed reasoning items before they go out. No record — fresh process, evicted,
expired, no client thread — keeps the blobs rather than discarding valid cached
reasoning on a guess; that leaves a switch spanning a proxy restart uncovered,
which the comment states rather than implies.

`status` is stripped only from items that are not forwarding a blob. An
OpenAI-operated backend binds the blob to the item's exact shape, so removing any
field from an item we still expect it to decode can invalidate it — the same
failure an unconditional `content` strip already produced once on this codebase.
Content blanking predates that invariant and is unchanged; an item carrying both
a native blob and raw content is a known unresolved conflict, noted in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The serving-identity record compared `credentialIdentity`, which for OAuth is
`accountId + generation` and therefore changes on every token refresh. Six of the
eight `bindRouteReasoningReplayScope` call sites are key-rotation or OAuth-refresh
rebinds, so an ordinary refresh registered as "the backend changed" and the next
turn on that thread dropped a valid blob. Key-pool providers would have paid that
repeatedly, and silently — nothing errors, the model just loses cached reasoning.

The module already distinguishes the durable dimensions for exactly this reason
(lidge-jun#1926: the rotating generation deliberately does not participate). The serving
record now compares `providerDestinationDurableIdentity` and
`credentialDurableIdentity`, and refuses to record at all when those are missing
rather than falling back to the volatile pair: a missed strip costs one degraded
turn, a spurious strip is a permanent quality regression. The proxy-owned replay
cache keeps its stricter key, which is deliberate.

Also documents two behaviours that would otherwise read as bugs: a combo that
rotates targets between turns legitimately drops blobs while the SSE model-name
rewrite hides the switch from the client, and the image/web-search loops consume
the replay scope without rebinding, which is what stops an internal small-model
call from poisoning the record for the main conversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 99daa756-5716-46ce-aba3-29034016f830

📥 Commits

Reviewing files that changed from the base of the PR and between 826a1b7 and 4f9f728.

📒 Files selected for processing (7)
  • src/adapters/openai-responses.ts
  • src/responses/reasoning-replay-cache.ts
  • src/server/responses/core.ts
  • src/types/request.ts
  • structure/04_transports-and-sidecars.md
  • tests/openai-responses-passthrough.test.ts
  • tests/reasoning-replay-identity.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change adds bounded serving-identity tracking for reasoning replay. Confirmed route changes mark requests for encrypted-content removal. Responses passthrough sanitization preserves or removes reasoning fields based on that marker, with regression tests and updated transport documentation.

Changes

Reasoning replay sanitization

Layer / File(s) Summary
Bounded serving-identity tracking
src/responses/reasoning-replay-cache.ts, tests/reasoning-replay-identity.test.ts
The replay cache validates five-part identities, detects durable route changes, expires entries, and enforces bounded storage. Tests cover ignored credential generations, invalid identities, expiration, and eviction.
Route-switch propagation
src/server/responses/core.ts, src/types/request.ts, structure/04_transports-and-sidecars.md
The response core records serving identity changes and sets _stripReasoningEncryptedContent after a confirmed change. Documentation covers combo rotation and auxiliary replay scopes.
Reasoning-item sanitization and passthrough validation
src/adapters/openai-responses.ts, tests/openai-responses-passthrough.test.ts
The sanitizer conditionally removes encrypted_content, removes status when no encrypted content remains, and preserves raw-content blanking. Tests cover unchanged routes, unknown identity state, confirmed switches, and blobless items.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🟡 Moderate · up to 4f9f7

The replay-state change is narrowly scoped and the reported cross-backend checks succeed, but the PR is not merge-ready because the submitted test run still has one failing test and the required readiness checklist remains incomplete; merge should wait until this is cleared or explicitly accepted.

Sequence Diagram(s)

sequenceDiagram
  participant ResponsesRequest
  participant responsesCore
  participant reasoningReplayCache
  participant OcxParsedRequest
  participant openaiResponses
  ResponsesRequest->>responsesCore: bind reasoning replay scope
  responsesCore->>reasoningReplayCache: updateReasoningReplayServingIdentity(scope)
  reasoningReplayCache-->>responsesCore: return route change status
  responsesCore->>OcxParsedRequest: set _stripReasoningEncryptedContent when changed
  OcxParsedRequest->>openaiResponses: sanitize passthrough request
  openaiResponses-->>ResponsesRequest: preserve or remove encrypted reasoning fields
Loading

Suggested reviewers: luvs01

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 6 files. (1 skipped: 1 unsupported.) Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: stripping cross-backend reasoning state when the model changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 21, 2026
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • review readiness checklist open (0/4 boxes ticked).

What to do

  • Tick all four boxes in the PR description once you're done (currently 0/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ⬜ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

0/4 boxes ticked.

This PR stays in draft until every box above is ticked.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 4f9f7288419b3fdcbf1613dfb7a6779bac6b69a8.

The serving-identity record is committed too early. bindRouteReasoningReplayScope() calls updateReasoningReplayServingIdentity() while binding the outbound route, before that route has produced a successful terminal response. The helper both compares and writes the current identity.

That advances the thread incorrectly on a failed switch:

  1. Route A is the last successful serving identity.
  2. A request switches to route B, so A's opaque reasoning is stripped, but B fails before a successful terminal response (network/auth/upstream failure).
  3. The store has already been changed to B.
  4. The next B retry compares equal and may preserve the client's replayed A blob, causing the same foreign-state failure this PR is intended to prevent.

Please separate comparison from commit. Use the last successfully served identity to decide whether this request must strip foreign opaque state, and record the candidate identity only after a successful terminal response from that route. Add a regression covering A success -> B switch failure -> B retry, asserting that the retry still strips the A blob and that failed attempts do not advance the recorded serving identity.

Once that success-boundary is enforced, the bounded/durable identity direction remains valuable.

@olddonkey

Copy link
Copy Markdown
Contributor Author

Superseded by #2254, which carries this change plus the rest of the series as a single review target.

These eight PRs had to merge in a strict order, and the later four each carried the whole series as their diff (up to 27 files / +2830), so reviewing them in isolation was not actually possible. #2254 has the same 16 commits with each unit's evidence intact in its message, and the combined test gate.

Nothing is dropped — the branch is unchanged and still pushed, so this can be reopened if a split is preferred after all.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants