Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions devlog/_plan/261102_request_owned_affinity/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 261102 — Request-owned bearer conversation affinity (#8080)

**Reader summary.** When Codex CLI signs in with ChatGPT and talks to a Pool-mode proxy, its own
bearer makes `main` one more round-robin candidate (#5019), but the request had no affinity key, so
every turn re-ran the strategy and a conversation hopped between main and the stored accounts. The
request log labelled each of those picks `new_bind/healthy` although nothing was stored. This unit
makes every affinity label describe what selection actually stored, and adds the opt-in
`codexPool.requestOwnedAffinity` (default off) that keeps one account per conversation for a
request-owned bearer. Research: `010_research.md`. Contract: `structure/providers/codex-request-owned-affinity.md`.

## Scope

- In: `src/codex/request-owned-affinity.ts` (key, label check, issuer helpers),
`src/codex/request-owned-main-pin.ts` (byte-for-byte move), wiring in `src/codex/auth-context.ts`,
request previews (`src/server/responses/request-prepare.ts`), issuer and retry/compact fallbacks,
request log and usage normalization, config field, manual-selection issuer retention.
- Out: affinity persistence (#8091), round-robin eligibility (#8092), Claude identity lanes.

## Decisions

1. Labels are checked when selection returns: `new_bind`/`rebound` stay only if the key's entry names
the selected account; otherwise `unbound` (`request_owned`, `no_conversation`, or the original
reason) or `cleared`. `unbound` is not a spend move reason.
2. The opt-in key is an HMAC under a process-random secret over the exact Authorization value and the
conversation key. Account-id claims are not used as principal.
3. Final auth and both previews (first and recovery, per candidate model) share one derivation; fixed
selectors and Reserve get no key; request-owned traffic never records lineage.
4. A separate bearer-scoped conversation-state key (never passed to routing) carries issuer records
for request-owned Pool and caller-owned main contexts, with or without the opt-in.
5. Manual account selection keeps issuer records while clearing routing bindings.
6. A refusal on a caller-owned main binding releases that binding without touching shared main health.

## Acceptance

Regression tests in `tests/codex-integration/codex-request-owned-affinity.test.ts`,
`tests/codex-integration/reserve-auth-context.test.ts`, `tests/responses/compact-transient-alternate.test.ts`,
`tests/responses/responses-alternate-main-cancellation.test.ts` and
`tests/responses/responses-preview-main-read-fence.test.ts` fail on the pre-change code and pass after it.

## Outcome

PR #8096. Plan audited over five rounds; implementation reviewed by a dedicated security review
and an independent code review before publication.
20 changes: 20 additions & 0 deletions devlog/_plan/261102_request_owned_affinity/010_research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 010 — Research (#8080)

Source: Terra research task, 2026-11-02, read-only, at 6f1d3e65c.

- Ownership flag: `codexRouteCredentialOwnership` (src/server/responses/core-auth.ts) marks a forwardable
caller bearer request-scoped.
- `poolStateEligible` (src/codex/auth-context.ts) rejects request-scoped credentials, so `affinityKey` and
lineage are undefined and `resolveCodexAccountForThreadDetailed` receives a null key.
- Since #5019/#5024 an unpinned caller bearer makes main an ordinary round-robin candidate; a stored
account selection materializes that stored credential. Intentional and documented in
structure/providers/openai-tiers.md.
- Round-robin commits its pick with a null key but binds only when `commitAffinity && threadId`
(src/codex/routing/selection.ts). With main + one stored account and sticky limit 1 the ring
alternates; with two stored accounts it cycles main, A, B.
- Every unbound success returns `affinityAfterRelease(null, undefined)` = `new_bind/healthy`.
- Log path: `noteCodexSelectionAffinity` (pool/main-pool only) → request log → `persistedAffinityFields`
→ usage.jsonl via `KNOWN_AFFINITY_MOVES/REASONS` (src/usage/log.ts). No exhaustive Record over the
unions; GUI does not render these values.
- Options weighed: opt-in process-local namespace (chosen); always serve main (contradicts #5019);
raise sticky limit (shared counter, not affinity); omit label only (honest but uninformative).
17 changes: 17 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,23 @@ When an account leaves pool selection, the reason travels with the decision inst

A main-account refresh that does not complete still answers `503` with `Retry-After`, because a retry may still succeed. The message now adds that a failure which persists means the main account needs reauthentication, rather than only asking for another attempt.

### Request-owned conversation affinity

`codexPool.requestOwnedAffinity` is an optional boolean, defaulting to `false`. Set it to `true`
to keep a conversation carrying its own ChatGPT bearer on one eligible account within each quota
scope. Releases and model detours can still move it. The binding is process-local; restarting the
proxy or rotating the bearer starts a new binding. Session-less multi-generation subagent chains
may bind per thread.

Affinity labels describe the binding when selection returns. `unbound` means no binding was stored;
`request_owned` means the caller-owned credential did not opt in, and `no_conversation` means no
conversation key was available. A later eligibility change can release a binding after selection.
Comment thread
lidge-jun marked this conversation as resolved.
Resolved

With or without the option, a request that carries its own bearer records which account served each
turn under a key scoped to that bearer. When a later turn is served by a different account, including
after you switch accounts manually, OpenCodex removes `previous_response_id` before sending and
rejects carried uploaded `file_id` references with HTTP 400, because the new account cannot read them.

### Optional idle-window steering

`codexPool.startIdleWindows` is an optional boolean and defaults to `false`. When enabled, a new
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@
"codex-plugins-doctor.test.ts": "codex-integration",
"codex-pool-plan-exclusion.test.ts": "codex-integration",
"codex-pool-refresh-backoff.test.ts": "codex-integration",
"codex-pool-request-owned-main.test.ts": "codex-integration",
"codex-pool-request-owned-main.test.ts": "codex-integration", "codex-request-owned-affinity.test.ts": "codex-integration",
"codex-pool-rotation.test.ts": "codex-integration", "codex-auth-release.test.ts": "codex-integration",
"codex-priority-failback.test.ts": "codex-integration", "codex-round-robin-headroom.test.ts": "codex-integration",
"codex-idle-window.test.ts": "codex-integration",
Expand Down
Loading
Loading