From d24ff57bc4dd53afbbb1d2c972266e6d89ea5c24 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 1/3] release: set main channel version 2.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d94d23cab..6547da6552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0", + "version": "2.48.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 588cb85c0db030c4eb939fa11bacb9ab42533442 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 14 Sep 2026 09:22:07 +0900 Subject: [PATCH 2/3] fix(codex): preserve cache affinity across model detours --- src/codex/routing.ts | 33 +++++++++++++++++-- structure/providers/openai-tiers.md | 7 ++-- tests/codex-integration/codex-routing.test.ts | 26 +++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 12c3e5e951..697c5727b9 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1505,6 +1505,32 @@ function hasCodexQuotaHeadroom( return usage < threshold; } +/** + * Whether quota may retire shared state while cache affinity is active. + * + * New/unbound selection still uses {@link hasCodexQuotaHeadroom}; only preservation of an + * existing shared selection or thread binding gets the exhaustion boundary. + */ +function hasCodexSharedStateQuotaHeadroom( + config: OcxConfig, + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, + now: number = Date.now(), +): boolean { + if ( + config.pool?.cacheAffinity !== true + || normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota" + ) { + return hasCodexQuotaHeadroom(config, accountId, selectionOptions, now); + } + const usage = computeCodexUsageScore( + getAccountQuota(accountId), + getPoolAccountPlanForSelection(config, accountId, selectionOptions), + now, + ); + return isUnknownUsage(usage) || usage < 100; +} + /** * Fill-first: keep selectable active under threshold; otherwise advance to the next * eligible id in stable sorted order after the current active (wrapping). @@ -2028,7 +2054,8 @@ function isHealthySharedCodexSelection( selectionOptions: CodexAccountUsabilityOptions | undefined, ): boolean { return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions) - && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, accountId, selectionOptions, now) + && !hasUnrecoveredCodexQuotaRefusal(accountId, quotaScope) && !shouldFailover(config, accountId, now); } @@ -2418,7 +2445,7 @@ export function resolveCodexAccountForThreadDetailed( // the account has already told this thread it cannot serve it. const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope); const healthyForSharedAffinity = selectableForSharedState - && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now) && !quotaRefused && !failoverReady; if ( @@ -2523,7 +2550,7 @@ export function resolveCodexAccountForThreadDetailed( sharedSelectionOptions, ); const activeHealthyForSharedSelection = activeSelectableForSharedState - && hasCodexQuotaHeadroom(config, active, sharedSelectionOptions, now) + && hasCodexSharedStateQuotaHeadroom(config, active, sharedSelectionOptions, now) && !shouldFailover(config, active, now); if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions); diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ff5f33d9a3..8fb8a77ceb 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -278,8 +278,11 @@ has headroom, auth resolution validates the caller bearer's own gated-model rost request-owned credential before stored-Pool selection. The credential never enters Pool persistence, affinity, entitlement cache, or health state, and this decision never reads the physical main credential. If the caller lacks the requested model, a stored-account model detour may serve the request without -clearing the healthy shared main pin. A paused or quota-drained main skips this exception and follows the -ordinary Pool promotion path. +clearing the healthy shared main pin. With quota-strategy cache affinity, the same detour preserves an +ordinary added-account binding and shared selection beyond the proactive-switch threshold until genuine +exhaustion; pause, cooldown, reauthentication, quota refusal, and failover evidence still retire shared +state normally. A paused or quota-drained main skips the request-owned credential exception and follows +the ordinary Pool promotion path. > Decision record: [ADR-0086](../decisions/ADR-0086-public-provider-contract.md) diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 72919b8dff..0eba1020a2 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -2452,6 +2452,32 @@ describe("codex account selection order", () => { expect(resolveCodexAccountForThread("model-gated-task", config, now + 2, "shared")).toBe("b"); }); + test("cache affinity preserves an over-threshold shared binding across a model detour", () => { + const config = orderedConfig({ + accountPoolStrategy: "quota", + activeCodexAccountId: "a", + activeCodexAccountPinned: "a", + autoSwitchThreshold: 80, + pool: { cacheAffinity: true }, + }); + const now = Date.now(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + + expect(resolveCodexAccountForThread("cache-affine-model-detour", config, now, "shared")).toBe("a"); + updateAccountQuota("a", 90); + expect(resolveCodexAccountForThreadDetailed( + "cache-affine-model-detour", + config, + now + 1, + "shared", + { modelEligibleAccountIds: new Set(["b"]) }, + )).toEqual({ status: "selected", accountId: "b" }); + + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("cache-affine-model-detour", config, now + 2, "shared")).toBe("a"); + }); + test("repeated model-gated round-robin requests reuse a separate detour affinity", () => { const now = 1_800_000_000_000; const threadId = "model-detour-affinity"; From 3639161d403fde3ccfe709390ee2b12f01496ba0 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 14 Sep 2026 09:32:34 +0900 Subject: [PATCH 3/3] fix(codex): honor disabled autoSwitchThreshold during cache affinity routing --- src/codex/routing.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 697c5727b9..b6b4982e1c 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1523,6 +1523,8 @@ function hasCodexSharedStateQuotaHeadroom( ) { return hasCodexQuotaHeadroom(config, accountId, selectionOptions, now); } + const threshold = config.pool?.autoSwitchThreshold; + if (typeof threshold === "number" && threshold <= 0) return true; const usage = computeCodexUsageScore( getAccountQuota(accountId), getPoolAccountPlanForSelection(config, accountId, selectionOptions),