Skip to content

feat(reasoning): hand a request to another account when this one cannot read its ciphertext - #247

Merged
HTExplicit merged 2 commits into
mainfrom
fix/ciphertext-account-switch
Oct 5, 2026
Merged

HTExplicit merged 2 commits into
mainfrom
fix/ciphertext-account-switch

Conversation

@HTExplicit

Copy link
Copy Markdown
Owner

Problem

A Codex conversation carries ciphertext that only the upstream organisation that issued it can read: reasoning items, the bodies of multi-agent agent_message items, encrypted tool-call arguments. In a group whose accounts belong to different organisations, an account of another organisation rejects the history with invalid_encrypted_content.

Reasoning recovery (#246) removes reasoning ciphertext and retries once on the same account. That cannot help when the rejected ciphertext is one the gateway cannot remove (an agent_message body, encrypted arguments), or when recovery is not possible. The request then ended with a request-scoped terminal, the account was never changed, and the sticky binding kept every client retry on the same account, although another account of the group might belong to the issuing organisation and accept the request as it is.

Owner decision 2026-10-05: try other accounts in that case.

Change

Service. StopError decides once, when an attempt ends, whether it ended as an account mismatch: the upstream said it could not decrypt or verify ciphertext in the request (structured code, the continuation-state message classification, or the wording could not be decrypted, which is how the rejection of an inter-agent message body arrives, without a code), nothing on this account repaired the request, and nothing of the attempt reached the client. The attempt's own failure is then returned marked (CiphertextAccountMismatch, next account, request scope, no health penalty, no same-account retry) instead of as a terminal.

Never a mismatch: the reasoning-recovery switch is off; semantic output was committed or a failure terminal forwarded; the request was cancelled; upstream status 401/402/403/407/429; the request carries previous_response_id or conversation; the stripped retry was prepared but not sent; an ordinary validation rejection, also after a stripped retry; a stripped retry that failed with a 5xx or a transport error.

Handler (POST /v1/responses, including compact and auto-passthrough). A mismatch hands the unchanged request to the next account, without the same-account retry policy, at most maxOpenAICiphertextAccountSwitches = 3 times per request and within the normal switch budget. Each account runs its own recovery. When accounts or budget run out the client receives the first mismatch failure, which is what it received before; an account that ends the request with an answer of its own is still the answer. Chat Completions, Messages and WebSocket are unchanged.

Sticky binding. Selecting a later account may move the session to it. When no account answered, a binding this request moved goes back to the account that first rejected the conversation; a binding the scheduler kept elsewhere (profit gate, sticky escape, spill-over) is not touched. An account that accepted, or was already producing output when its attempt failed, keeps the session; where selection does not bind (profit gate), a binding that still points at the rejecting account is moved to the accepting one.

Ops. The continuation diagnostic marks such an attempt with recovery.account_mismatch; an attempt recognised by message only is recorded too. No UI change.

Limits

  • The cap bounds the cost when the issuing account is gone: each rejection uploads the whole history, up to twice per account.
  • Moving from a passthrough account to a non-passthrough one still drops encrypted reasoning items (the existing cross-mode rule), so that account does not get the request unchanged.
  • A first rejection that arrives in-stream with an unrecognised code is forwarded to the client as before and cannot be switched.
  • The wording could not be decrypted comes from third-party reports; production has not yet produced a sample of a rejection after the stripped retry.
  • The response header X-Sub2API-Reasoning-Recovery names the last recovery action of the request, which may be an earlier account's.

Verification

  • go test -tags unit ./internal/handler/ (whole package, before the last round of fixes; the mismatch and recovery tests again after it).
  • go test -tags unit ./internal/service/ (whole package, same): one failure, TestOllamaProbeCallback_StaleLongDoesNotOverrideNewShort, which fails the same way on main on this Windows machine and passes in CI.
  • New handler tests: an accepting account receives the request unchanged and the session is bound to it; no account accepts (cap, binding back on the first account); accounts run out with differently classified failures, in both orders (first failure rendered); a later account's own validation answer ends the request; a 5xx-wrapped rejection on OAuth accounts is not retried on the same account; an account whose stream broke while answering keeps the session. Each was checked against a mutant of the production line it pins (cap, retry bypass, first-failure rendering, binding restore, answering account).
  • New and updated service tests: which attempts are marked and which are not (validation, server-held history with and without a wire snapshot, account status, switch off, output sent, failure forwarded, cancelled); the outcome matrix for native and passthrough over HTTP, SSE and buffered SSE; the conditional binding move; the state reset.
  • Two review rounds, each finding checked by a second reader. The first led to the narrower trigger, the binding restore and the Ops record for message-recognised mismatches; the second to the conditional binding move. No real model calls were made.

🤖 Generated with Claude Code

Codex and others added 2 commits October 5, 2026 13:44
…ot read its ciphertext

Ciphertext in a Codex conversation (reasoning items, inter-agent message
bodies, encrypted tool arguments) is readable only by the upstream
organisation that issued it. When an account of another organisation rejected
it and the stripped retry could not help, the request ended on that account
and every client retry hit the same account, although another account of the
group might accept the request unchanged.

StopError now decides once whether an attempt ended as an account mismatch:
the upstream said it could not decrypt or verify ciphertext in the request,
nothing on this account repaired it, and nothing of the attempt reached the
client. The attempt's own failure is returned marked for the next account, in
request scope and without a health penalty, instead of as a terminal. An
ordinary validation rejection, a request with server-held history, an account
status, a cancelled request and the switched-off recovery never are.

The Responses loop hands a mismatch to the next account without the
same-account retry policy, at most three times per request and within the
normal switch budget. When accounts run out the client receives the first
mismatch failure, as before; an account that ends the request with its own
answer is still the answer. A sticky binding this request moved goes back to
the first rejecting account when no account answered, and stays with an
account that accepted or was already answering; a binding the scheduler kept
elsewhere is not touched.

The continuation diagnostic marks such an attempt with
recovery.account_mismatch. Chat Completions, Messages and WebSocket are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HTExplicit
HTExplicit merged commit 8d1828b into main Oct 5, 2026
18 checks passed
@HTExplicit
HTExplicit deleted the fix/ciphertext-account-switch branch October 5, 2026 06:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant