@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DO8liQVL.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
- package/gui/dist/assets/index-DX0RGj3e.js +0 -112
package/src/codex/routing.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { codexAccountLogLabel } from "./account-label";
|
|
|
5
5
|
import { isCodexAccountPaused } from "./account-pause";
|
|
6
6
|
import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
|
|
7
7
|
import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
|
|
8
|
-
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
|
+
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
9
9
|
import {
|
|
10
10
|
POOL_KEY_CODEX,
|
|
11
11
|
normalizeAccountPoolStickyLimit,
|
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
} from "./pool-rotation";
|
|
20
20
|
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
21
21
|
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
MAIN_CODEX_ACCOUNT_ID,
|
|
24
|
+
getMainAccountPlan,
|
|
25
|
+
hasMainAccountRefreshGrant,
|
|
26
|
+
} from "./main-account";
|
|
23
27
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
24
28
|
import type { OcxConfig } from "../types";
|
|
25
29
|
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
@@ -87,6 +91,15 @@ type CodexUpstreamHealth = {
|
|
|
87
91
|
* flaky account without throwing CodexAccountCooldownError (hard-only).
|
|
88
92
|
*/
|
|
89
93
|
softAvoidUntil?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Credential generation a 401/403 quarantine was derived from (#2892 gap 4).
|
|
96
|
+
*
|
|
97
|
+
* Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends
|
|
98
|
+
* "whatever health is current when the old credential is found dead", which deletes a later
|
|
99
|
+
* unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the
|
|
100
|
+
* entry that carries this field can be spent, and any later write simply replaces it.
|
|
101
|
+
*/
|
|
102
|
+
credentialFailureGeneration?: number;
|
|
90
103
|
};
|
|
91
104
|
|
|
92
105
|
const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
@@ -123,6 +136,22 @@ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
|
123
136
|
* from account-wide Retry-After/default throttles and transient health.
|
|
124
137
|
*/
|
|
125
138
|
const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
|
|
139
|
+
/**
|
|
140
|
+
* Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4).
|
|
141
|
+
*
|
|
142
|
+
* A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after
|
|
143
|
+
* the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the
|
|
144
|
+
* window without closing it. The reader decides instead, and it may only spend an entry that
|
|
145
|
+
* actually carries credential provenance: a later transient or quota write replaces the entry and
|
|
146
|
+
* with it the tag, so this can never delete evidence that belongs to a different failure.
|
|
147
|
+
*/
|
|
148
|
+
function dropSpentCredentialFailure(accountId: string): void {
|
|
149
|
+
const health = upstreamHealth.get(accountId);
|
|
150
|
+
const generation = health?.credentialFailureGeneration;
|
|
151
|
+
if (health === undefined || generation === undefined) return;
|
|
152
|
+
if (isCodexAccountGenerationLive(accountId, generation)) return;
|
|
153
|
+
upstreamHealth.delete(accountId);
|
|
154
|
+
}
|
|
126
155
|
let lastReconciledGeneration = 0;
|
|
127
156
|
let liveHealthAccountIds = new Set<string>();
|
|
128
157
|
|
|
@@ -158,22 +187,21 @@ export type CodexQuotaRecoveryProbeProof = {
|
|
|
158
187
|
* affinity so a Spark failover cannot displace the same thread's Terra/Luna
|
|
159
188
|
* account (and vice versa).
|
|
160
189
|
*/
|
|
161
|
-
type
|
|
190
|
+
type BaseThreadAffinityScope = CodexQuotaScope | "legacy";
|
|
191
|
+
type ModelDetourAffinityScope = `model-detour:${BaseThreadAffinityScope}:${string}`;
|
|
192
|
+
type ThreadAffinityScope = BaseThreadAffinityScope | ModelDetourAffinityScope;
|
|
162
193
|
const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
|
|
163
194
|
const threadAccountMap = new Map<string, Map<ThreadAffinityScope, ThreadAffinityEntry>>();
|
|
195
|
+
let threadAffinityEntryTotal = 0;
|
|
196
|
+
|
|
197
|
+
function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope {
|
|
198
|
+
return scope.startsWith("model-detour:");
|
|
199
|
+
}
|
|
164
200
|
|
|
165
201
|
const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
|
|
166
202
|
"gpt-5.3-codex-spark": "spark",
|
|
167
203
|
};
|
|
168
204
|
|
|
169
|
-
// A thread can have one legacy binding plus one binding for each known scope.
|
|
170
|
-
// This upper-bound guard avoids an exact map scan until it can be over capacity.
|
|
171
|
-
const MAX_THREAD_AFFINITY_SCOPES = new Set([
|
|
172
|
-
LEGACY_THREAD_AFFINITY_SCOPE,
|
|
173
|
-
"shared",
|
|
174
|
-
...Object.values(NATIVE_MODEL_QUOTA_SCOPES),
|
|
175
|
-
]).size;
|
|
176
|
-
|
|
177
205
|
export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
|
|
178
206
|
if (!modelId?.trim()) return undefined;
|
|
179
207
|
return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
|
|
@@ -228,6 +256,15 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
228
256
|
promoteAccountId?: string;
|
|
229
257
|
/** Generation captured when this routed account was selected. */
|
|
230
258
|
writerGeneration?: number;
|
|
259
|
+
/**
|
|
260
|
+
* Credential generation this request's bearer was read at. Distinct from
|
|
261
|
+
* `writerGeneration`, which tracks the config store.
|
|
262
|
+
*
|
|
263
|
+
* A 401 that arrives after the credential was already replaced is evidence about a
|
|
264
|
+
* token nobody is using any more, so it must not quarantine the replacement. Absent
|
|
265
|
+
* means the caller cannot supply lineage and the historical unfenced handling stands.
|
|
266
|
+
*/
|
|
267
|
+
credentialGeneration?: number;
|
|
231
268
|
};
|
|
232
269
|
|
|
233
270
|
function hasConfiguredPoolAccount(
|
|
@@ -253,12 +290,15 @@ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string>
|
|
|
253
290
|
|
|
254
291
|
export function clearThreadAccountMap(): void {
|
|
255
292
|
threadAccountMap.clear();
|
|
293
|
+
threadAffinityEntryTotal = 0;
|
|
256
294
|
}
|
|
257
295
|
|
|
258
296
|
export function clearThreadAccountMapForAccount(accountId: string): void {
|
|
259
297
|
for (const [threadId, affinities] of threadAccountMap) {
|
|
260
298
|
for (const [scope, entry] of affinities) {
|
|
261
|
-
if (entry.accountId === accountId
|
|
299
|
+
if (entry.accountId === accountId && affinities.delete(scope)) {
|
|
300
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
301
|
+
}
|
|
262
302
|
}
|
|
263
303
|
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
264
304
|
}
|
|
@@ -296,6 +336,7 @@ export function reconcileCodexRoutingHealth(context: GenerationContext): number
|
|
|
296
336
|
export function getCodexUpstreamHealth(
|
|
297
337
|
accountId: string,
|
|
298
338
|
): CodexUpstreamHealth | null {
|
|
339
|
+
dropSpentCredentialFailure(accountId);
|
|
299
340
|
return upstreamHealth.get(accountId) ?? null;
|
|
300
341
|
}
|
|
301
342
|
|
|
@@ -675,7 +716,13 @@ function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): Codex
|
|
|
675
716
|
*/
|
|
676
717
|
function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
|
|
677
718
|
if (!health) return {};
|
|
678
|
-
|
|
719
|
+
// `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive
|
|
720
|
+
// into a later transient or quota entry — otherwise that entry inherits the tag and gets spent
|
|
721
|
+
// when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review).
|
|
722
|
+
const {
|
|
723
|
+
consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at,
|
|
724
|
+
softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields
|
|
725
|
+
} = health;
|
|
679
726
|
return cooldownFields;
|
|
680
727
|
}
|
|
681
728
|
|
|
@@ -830,7 +877,7 @@ function isCodexAccountSelectable(
|
|
|
830
877
|
&& isCodexAccountUsable(config, accountId, selectionOptions);
|
|
831
878
|
}
|
|
832
879
|
|
|
833
|
-
function threadAffinityScope(quotaScope?: CodexQuotaScope):
|
|
880
|
+
function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope {
|
|
834
881
|
return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
|
|
835
882
|
}
|
|
836
883
|
|
|
@@ -838,34 +885,74 @@ function admissibleAffinityComponent(value: string): boolean {
|
|
|
838
885
|
return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
|
|
839
886
|
}
|
|
840
887
|
|
|
841
|
-
function
|
|
888
|
+
function modelDetourAffinityScope(
|
|
889
|
+
modelId: string | undefined,
|
|
890
|
+
quotaScope?: CodexQuotaScope,
|
|
891
|
+
): ModelDetourAffinityScope | undefined {
|
|
892
|
+
const canonicalModelId = modelId?.trim().toLowerCase();
|
|
893
|
+
if (!canonicalModelId || !admissibleAffinityComponent(canonicalModelId)) return undefined;
|
|
894
|
+
return `model-detour:${threadAffinityScope(quotaScope)}:${canonicalModelId}`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function getThreadAffinityForScope(
|
|
898
|
+
threadId: string,
|
|
899
|
+
scope: ThreadAffinityScope,
|
|
900
|
+
): ThreadAffinityEntry | undefined {
|
|
842
901
|
if (!admissibleAffinityComponent(threadId)) return undefined;
|
|
843
|
-
return threadAccountMap.get(threadId)?.get(
|
|
902
|
+
return threadAccountMap.get(threadId)?.get(scope);
|
|
844
903
|
}
|
|
845
904
|
|
|
846
|
-
function
|
|
905
|
+
function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
|
|
906
|
+
return getThreadAffinityForScope(threadId, threadAffinityScope(quotaScope));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function getModelDetourAffinity(
|
|
910
|
+
threadId: string,
|
|
911
|
+
modelId: string | undefined,
|
|
912
|
+
quotaScope?: CodexQuotaScope,
|
|
913
|
+
): ThreadAffinityEntry | undefined {
|
|
914
|
+
const scope = modelDetourAffinityScope(modelId, quotaScope);
|
|
915
|
+
return scope ? getThreadAffinityForScope(threadId, scope) : undefined;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function deleteThreadAffinityForScope(threadId: string, scope: ThreadAffinityScope): void {
|
|
847
919
|
if (!admissibleAffinityComponent(threadId)) return;
|
|
848
920
|
const affinities = threadAccountMap.get(threadId);
|
|
849
921
|
if (!affinities) return;
|
|
850
|
-
affinities.delete(
|
|
922
|
+
if (affinities.delete(scope)) {
|
|
923
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
924
|
+
}
|
|
851
925
|
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
852
926
|
}
|
|
853
927
|
|
|
928
|
+
function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
|
|
929
|
+
deleteThreadAffinityForScope(threadId, threadAffinityScope(quotaScope));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function deleteModelDetourAffinity(
|
|
933
|
+
threadId: string,
|
|
934
|
+
modelId: string | undefined,
|
|
935
|
+
quotaScope?: CodexQuotaScope,
|
|
936
|
+
): void {
|
|
937
|
+
const scope = modelDetourAffinityScope(modelId, quotaScope);
|
|
938
|
+
if (scope) deleteThreadAffinityForScope(threadId, scope);
|
|
939
|
+
}
|
|
940
|
+
|
|
854
941
|
/** Remove only the matching failed account's affinities for one thread. */
|
|
855
942
|
function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
|
|
856
943
|
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
857
944
|
const affinities = threadAccountMap.get(threadId);
|
|
858
945
|
if (!affinities) return;
|
|
859
946
|
for (const [scope, entry] of affinities) {
|
|
860
|
-
if (entry.accountId === accountId
|
|
947
|
+
if (entry.accountId === accountId && affinities.delete(scope)) {
|
|
948
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
949
|
+
}
|
|
861
950
|
}
|
|
862
951
|
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
863
952
|
}
|
|
864
953
|
|
|
865
954
|
function threadAffinityEntryCount(): number {
|
|
866
|
-
|
|
867
|
-
for (const affinities of threadAccountMap.values()) count += affinities.size;
|
|
868
|
-
return count;
|
|
955
|
+
return threadAffinityEntryTotal;
|
|
869
956
|
}
|
|
870
957
|
|
|
871
958
|
function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
|
|
@@ -877,46 +964,102 @@ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
|
|
|
877
964
|
return isCodexAccountGenerationLive(entry.accountId, entry.generation);
|
|
878
965
|
}
|
|
879
966
|
|
|
967
|
+
/** Generations this account's affinity entries are bound at. Test observability only. */
|
|
968
|
+
export function debugCodexAffinityGenerations(accountId: string): number[] {
|
|
969
|
+
const generations: number[] = [];
|
|
970
|
+
for (const affinities of threadAccountMap.values()) {
|
|
971
|
+
for (const entry of affinities.values()) {
|
|
972
|
+
if (entry.accountId === accountId) generations.push(entry.generation);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return generations;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Advance this account's affinity entries from the generation a rejected credential
|
|
980
|
+
* was bound under to the generation its own refresh produced.
|
|
981
|
+
*
|
|
982
|
+
* A 401 refresh-and-replay keeps the request on the same account, but the CAS write
|
|
983
|
+
* moves the credential from G to G+1, and {@link isThreadAffinityGenerationLive}
|
|
984
|
+
* demands exact equality — so without this the entry the replay just preserved is
|
|
985
|
+
* dead on the next request. Not quarantining an account is not the same as keeping
|
|
986
|
+
* its affinity.
|
|
987
|
+
*
|
|
988
|
+
* Lineage is proven by the CALLER, which must pass only a generation its own refresh
|
|
989
|
+
* produced. Re-deriving it here from `replacedAt` cannot work: the caller reads that
|
|
990
|
+
* field after the refresh and this function would re-read the same record, so the
|
|
991
|
+
* comparison is tautological and an external replacement passes it. An external
|
|
992
|
+
* replacement must retire the affinity, because that credential may belong to a
|
|
993
|
+
* different upstream identity.
|
|
994
|
+
*/
|
|
995
|
+
export function handOffThreadAffinityGeneration(
|
|
996
|
+
accountId: string,
|
|
997
|
+
fromGeneration: number,
|
|
998
|
+
toGeneration: number,
|
|
999
|
+
): boolean {
|
|
1000
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
|
|
1001
|
+
if (toGeneration !== fromGeneration + 1) return false;
|
|
1002
|
+
const record = readCodexAccountRecord(accountId);
|
|
1003
|
+
if (!record?.credential || record.deletedAt != null) return false;
|
|
1004
|
+
if (record.generation !== toGeneration) return false;
|
|
1005
|
+
let handedOff = false;
|
|
1006
|
+
for (const affinities of threadAccountMap.values()) {
|
|
1007
|
+
for (const entry of affinities.values()) {
|
|
1008
|
+
if (entry.accountId !== accountId || entry.generation !== fromGeneration) continue;
|
|
1009
|
+
entry.generation = toGeneration;
|
|
1010
|
+
handedOff = true;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return handedOff;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
880
1016
|
function pruneExpiredThreadAffinities(now: number): void {
|
|
881
1017
|
for (const [threadId, affinities] of threadAccountMap) {
|
|
882
1018
|
for (const [scope, entry] of affinities) {
|
|
883
|
-
if (isThreadAffinityExpired(entry, now)
|
|
1019
|
+
if (isThreadAffinityExpired(entry, now) && affinities.delete(scope)) {
|
|
1020
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
1021
|
+
}
|
|
884
1022
|
}
|
|
885
1023
|
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
886
1024
|
}
|
|
887
1025
|
}
|
|
888
1026
|
|
|
889
1027
|
function pruneLruThreadAffinities(): void {
|
|
890
|
-
if (
|
|
1028
|
+
if (threadAffinityEntryCount() <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
|
|
891
1029
|
while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
|
|
892
1030
|
let oldestThreadId: string | null = null;
|
|
893
1031
|
let oldestScope: ThreadAffinityScope | null = null;
|
|
894
1032
|
let oldestLastUsedAt = Number.POSITIVE_INFINITY;
|
|
1033
|
+
let oldestIsDetour = false;
|
|
895
1034
|
for (const [threadId, affinities] of threadAccountMap) {
|
|
896
1035
|
for (const [scope, entry] of affinities) {
|
|
897
|
-
|
|
1036
|
+
const candidateIsDetour = isModelDetourAffinityScope(scope);
|
|
1037
|
+
if (
|
|
1038
|
+
(candidateIsDetour && !oldestIsDetour)
|
|
1039
|
+
|| (candidateIsDetour === oldestIsDetour && entry.lastUsedAt < oldestLastUsedAt)
|
|
1040
|
+
) {
|
|
898
1041
|
oldestThreadId = threadId;
|
|
899
1042
|
oldestScope = scope;
|
|
900
1043
|
oldestLastUsedAt = entry.lastUsedAt;
|
|
1044
|
+
oldestIsDetour = candidateIsDetour;
|
|
901
1045
|
}
|
|
902
1046
|
}
|
|
903
1047
|
}
|
|
904
1048
|
if (!oldestThreadId || !oldestScope) return;
|
|
905
|
-
|
|
1049
|
+
deleteThreadAffinityForScope(oldestThreadId, oldestScope);
|
|
906
1050
|
}
|
|
907
1051
|
}
|
|
908
1052
|
|
|
909
|
-
function
|
|
1053
|
+
function bindThreadAffinityForScope(
|
|
910
1054
|
threadId: string,
|
|
911
1055
|
accountId: string,
|
|
912
1056
|
now: number,
|
|
913
|
-
|
|
1057
|
+
scope: ThreadAffinityScope,
|
|
914
1058
|
): void {
|
|
915
1059
|
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
916
1060
|
const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
|
|
917
1061
|
if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
|
|
918
1062
|
pruneExpiredThreadAffinities(now);
|
|
919
|
-
const scope = threadAffinityScope(quotaScope);
|
|
920
1063
|
const affinities = threadAccountMap.get(threadId) ?? new Map<ThreadAffinityScope, ThreadAffinityEntry>();
|
|
921
1064
|
const previous = affinities.get(scope);
|
|
922
1065
|
affinities.set(scope, {
|
|
@@ -926,22 +1069,45 @@ function bindThreadAffinity(
|
|
|
926
1069
|
lastUsedAt: now,
|
|
927
1070
|
lastReevalAt: now,
|
|
928
1071
|
});
|
|
1072
|
+
if (!previous) threadAffinityEntryTotal += 1;
|
|
929
1073
|
threadAccountMap.set(threadId, affinities);
|
|
930
1074
|
pruneLruThreadAffinities();
|
|
931
1075
|
}
|
|
932
1076
|
|
|
1077
|
+
function bindThreadAffinity(
|
|
1078
|
+
threadId: string,
|
|
1079
|
+
accountId: string,
|
|
1080
|
+
now: number,
|
|
1081
|
+
quotaScope?: CodexQuotaScope,
|
|
1082
|
+
): void {
|
|
1083
|
+
bindThreadAffinityForScope(threadId, accountId, now, threadAffinityScope(quotaScope));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function bindModelDetourAffinity(
|
|
1087
|
+
threadId: string,
|
|
1088
|
+
accountId: string,
|
|
1089
|
+
now: number,
|
|
1090
|
+
modelId: string | undefined,
|
|
1091
|
+
quotaScope?: CodexQuotaScope,
|
|
1092
|
+
): void {
|
|
1093
|
+
const scope = modelDetourAffinityScope(modelId, quotaScope);
|
|
1094
|
+
if (scope) bindThreadAffinityForScope(threadId, accountId, now, scope);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
933
1097
|
function getEligiblePoolAccounts(
|
|
934
1098
|
config: OcxConfig,
|
|
935
1099
|
excludeId?: string,
|
|
936
1100
|
now = Date.now(),
|
|
937
1101
|
quotaScope?: CodexQuotaScope,
|
|
938
1102
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1103
|
+
skipFailoverReadyCandidates = false,
|
|
939
1104
|
): readonly string[] {
|
|
940
1105
|
const ids = (config.codexAccounts ?? [])
|
|
941
1106
|
.filter(account => isSelectableCodexPoolAccount(account)
|
|
942
1107
|
&& account.id !== excludeId
|
|
943
1108
|
&& !isCodexAccountPaused(config, account.id)
|
|
944
|
-
&& !isAccountNeedsReauth(account.id)
|
|
1109
|
+
&& !isAccountNeedsReauth(account.id)
|
|
1110
|
+
&& (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
|
|
945
1111
|
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
|
946
1112
|
.filter(account => !isCodexAccountSoftAvoided(account.id, now))
|
|
947
1113
|
.filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
|
|
@@ -951,9 +1117,10 @@ function getEligiblePoolAccounts(
|
|
|
951
1117
|
if (
|
|
952
1118
|
excludeId !== MAIN_CODEX_ACCOUNT_ID
|
|
953
1119
|
&& !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
954
|
-
&& !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|
|
1120
|
+
&& (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
|
|
955
1121
|
&& getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
|
|
956
1122
|
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
1123
|
+
&& (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
|
|
957
1124
|
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
|
|
958
1125
|
) {
|
|
959
1126
|
ids.unshift(MAIN_CODEX_ACCOUNT_ID);
|
|
@@ -964,7 +1131,7 @@ function getEligiblePoolAccounts(
|
|
|
964
1131
|
return selectPriorityTier(
|
|
965
1132
|
ids,
|
|
966
1133
|
codexAccountPriorityLookup(config),
|
|
967
|
-
id => hasCodexQuotaHeadroom(config, id),
|
|
1134
|
+
id => hasCodexQuotaHeadroom(config, id, selectionOptions),
|
|
968
1135
|
pinnedCodexAccountId(config),
|
|
969
1136
|
);
|
|
970
1137
|
}
|
|
@@ -992,10 +1159,17 @@ function stickyLimitForConfig(config: OcxConfig): number {
|
|
|
992
1159
|
* primed. A genuinely exhausted account 429s into cooldown and leaves
|
|
993
1160
|
* eligibility on its own.
|
|
994
1161
|
*/
|
|
995
|
-
function hasCodexQuotaHeadroom(
|
|
1162
|
+
function hasCodexQuotaHeadroom(
|
|
1163
|
+
config: OcxConfig,
|
|
1164
|
+
accountId: string,
|
|
1165
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1166
|
+
): boolean {
|
|
996
1167
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
997
1168
|
if (threshold <= 0) return true;
|
|
998
|
-
const usage = computeCodexUsageScore(
|
|
1169
|
+
const usage = computeCodexUsageScore(
|
|
1170
|
+
getAccountQuota(accountId),
|
|
1171
|
+
getPoolAccountPlanForSelection(config, accountId, selectionOptions),
|
|
1172
|
+
);
|
|
999
1173
|
if (isUnknownUsage(usage)) return true;
|
|
1000
1174
|
return usage < threshold;
|
|
1001
1175
|
}
|
|
@@ -1014,7 +1188,7 @@ function pickFillFirstCodexAccount(
|
|
|
1014
1188
|
if (eligible.length === 0) return null;
|
|
1015
1189
|
|
|
1016
1190
|
const active = getEffectiveActiveCodexAccountId(config);
|
|
1017
|
-
if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) {
|
|
1191
|
+
if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions)) {
|
|
1018
1192
|
return active;
|
|
1019
1193
|
}
|
|
1020
1194
|
|
|
@@ -1034,7 +1208,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
1034
1208
|
if (!afterId) {
|
|
1035
1209
|
// Prefer an under-threshold account when starting with no active cursor.
|
|
1036
1210
|
for (const id of ordered) {
|
|
1037
|
-
if (hasCodexQuotaHeadroom(config, id)) return id;
|
|
1211
|
+
if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id;
|
|
1038
1212
|
}
|
|
1039
1213
|
return ordered[0] ?? null;
|
|
1040
1214
|
}
|
|
@@ -1049,7 +1223,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
1049
1223
|
const startIdx = stableAll.indexOf(afterId);
|
|
1050
1224
|
if (startIdx < 0) {
|
|
1051
1225
|
for (const id of ordered) {
|
|
1052
|
-
if (hasCodexQuotaHeadroom(config, id)) return id;
|
|
1226
|
+
if (hasCodexQuotaHeadroom(config, id, selectionOptions)) return id;
|
|
1053
1227
|
}
|
|
1054
1228
|
return ordered[0] ?? null;
|
|
1055
1229
|
}
|
|
@@ -1060,7 +1234,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
1060
1234
|
const candidate = stableAll[(startIdx + step) % stableAll.length]!;
|
|
1061
1235
|
if (!eligible.includes(candidate)) continue;
|
|
1062
1236
|
if (!fallback) fallback = candidate;
|
|
1063
|
-
if (hasCodexQuotaHeadroom(config, candidate)) return candidate;
|
|
1237
|
+
if (hasCodexQuotaHeadroom(config, candidate, selectionOptions)) return candidate;
|
|
1064
1238
|
}
|
|
1065
1239
|
return fallback ?? ordered[0] ?? null;
|
|
1066
1240
|
}
|
|
@@ -1069,10 +1243,10 @@ function pickNextFillFirstCodexAccount(
|
|
|
1069
1243
|
* Unbound new-session pick for round-robin / fill-first. Returns null to fall through
|
|
1070
1244
|
* to the legacy quota path (or when the strategy is quota).
|
|
1071
1245
|
*
|
|
1072
|
-
* When `commit` is true (resolve path),
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1246
|
+
* When `commit` is true (resolve path), advances RR state. `commitSharedActive`
|
|
1247
|
+
* and `commitAffinity` independently control the two cross-request side effects:
|
|
1248
|
+
* model-scoped entitlement selection can bind a new task without replacing an
|
|
1249
|
+
* existing task binding or global active choice. Preview remains a dry-run peek.
|
|
1076
1250
|
*
|
|
1077
1251
|
* Automatic strategy picks never sync-write config; only manual selection persists active.
|
|
1078
1252
|
*
|
|
@@ -1087,6 +1261,8 @@ function pickUnboundStrategyAccount(
|
|
|
1087
1261
|
commit: boolean,
|
|
1088
1262
|
quotaScope?: CodexQuotaScope,
|
|
1089
1263
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1264
|
+
commitSharedActive = commit,
|
|
1265
|
+
commitAffinity = commit,
|
|
1090
1266
|
): string | null {
|
|
1091
1267
|
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
1092
1268
|
if (strategy === "quota") return null;
|
|
@@ -1101,8 +1277,10 @@ function pickUnboundStrategyAccount(
|
|
|
1101
1277
|
}
|
|
1102
1278
|
picked = pickRoundRobinAccount(poolKey, eligible, limit);
|
|
1103
1279
|
if (!picked) return null;
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1280
|
+
if (commitSharedActive) {
|
|
1281
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
|
|
1282
|
+
}
|
|
1283
|
+
if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
1106
1284
|
notePoolRotationSuccess(poolKey, picked, limit);
|
|
1107
1285
|
return picked;
|
|
1108
1286
|
}
|
|
@@ -1110,10 +1288,10 @@ function pickUnboundStrategyAccount(
|
|
|
1110
1288
|
if (strategy === "fill-first") {
|
|
1111
1289
|
picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
|
|
1112
1290
|
if (!picked) return null;
|
|
1113
|
-
if (
|
|
1291
|
+
if (commitSharedActive) {
|
|
1114
1292
|
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
|
|
1115
|
-
if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
1116
1293
|
}
|
|
1294
|
+
if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
1117
1295
|
return picked;
|
|
1118
1296
|
}
|
|
1119
1297
|
|
|
@@ -1126,6 +1304,36 @@ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string
|
|
|
1126
1304
|
.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
|
|
1127
1305
|
}
|
|
1128
1306
|
|
|
1307
|
+
/** Selection-only main routing must not lazily read the fenced native credential for its plan. */
|
|
1308
|
+
function getPoolAccountPlanForSelection(
|
|
1309
|
+
config: OcxConfig,
|
|
1310
|
+
accountId: string,
|
|
1311
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1312
|
+
): string | undefined {
|
|
1313
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
|
|
1314
|
+
return undefined;
|
|
1315
|
+
}
|
|
1316
|
+
return getPoolAccountPlan(config, accountId);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Shared routing state must ignore a request-scoped entitlement roster. */
|
|
1320
|
+
function sharedStateSelectionOptions(
|
|
1321
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1322
|
+
): Pick<
|
|
1323
|
+
CodexAccountUsabilityOptions,
|
|
1324
|
+
"nativeMainSelectionOnly" | "isMainAccountTokenLive"
|
|
1325
|
+
> | undefined {
|
|
1326
|
+
if (!selectionOptions) return undefined;
|
|
1327
|
+
return {
|
|
1328
|
+
...(selectionOptions.nativeMainSelectionOnly !== undefined
|
|
1329
|
+
? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly }
|
|
1330
|
+
: {}),
|
|
1331
|
+
...(selectionOptions.isMainAccountTokenLive
|
|
1332
|
+
? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
|
|
1333
|
+
: {}),
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1129
1337
|
function pickLowerUsageAccount(
|
|
1130
1338
|
config: OcxConfig,
|
|
1131
1339
|
active: string,
|
|
@@ -1133,11 +1341,22 @@ function pickLowerUsageAccount(
|
|
|
1133
1341
|
now: number,
|
|
1134
1342
|
quotaScope?: CodexQuotaScope,
|
|
1135
1343
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1344
|
+
skipFailoverReadyCandidates = false,
|
|
1136
1345
|
): string {
|
|
1137
1346
|
let best = active;
|
|
1138
1347
|
let bestUsage = activeUsage;
|
|
1139
|
-
for (const id of getEligiblePoolAccounts(
|
|
1140
|
-
|
|
1348
|
+
for (const id of getEligiblePoolAccounts(
|
|
1349
|
+
config,
|
|
1350
|
+
active,
|
|
1351
|
+
now,
|
|
1352
|
+
quotaScope,
|
|
1353
|
+
selectionOptions,
|
|
1354
|
+
skipFailoverReadyCandidates,
|
|
1355
|
+
)) {
|
|
1356
|
+
const usage = computeCodexUsageScore(
|
|
1357
|
+
getAccountQuota(id),
|
|
1358
|
+
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
1359
|
+
);
|
|
1141
1360
|
if (usage < bestUsage) {
|
|
1142
1361
|
best = id;
|
|
1143
1362
|
bestUsage = usage;
|
|
@@ -1147,11 +1366,18 @@ function pickLowerUsageAccount(
|
|
|
1147
1366
|
}
|
|
1148
1367
|
|
|
1149
1368
|
/** Coolest account in an already-selected candidate list; first index wins ties. */
|
|
1150
|
-
function pickLowestUsageAmong(
|
|
1369
|
+
function pickLowestUsageAmong(
|
|
1370
|
+
config: OcxConfig,
|
|
1371
|
+
ids: readonly string[],
|
|
1372
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1373
|
+
): string | null {
|
|
1151
1374
|
let best: string | null = null;
|
|
1152
1375
|
let bestUsage = Number.POSITIVE_INFINITY;
|
|
1153
1376
|
for (const id of ids) {
|
|
1154
|
-
const usage = computeCodexUsageScore(
|
|
1377
|
+
const usage = computeCodexUsageScore(
|
|
1378
|
+
getAccountQuota(id),
|
|
1379
|
+
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
1380
|
+
);
|
|
1155
1381
|
if (usage < bestUsage) {
|
|
1156
1382
|
best = id;
|
|
1157
1383
|
bestUsage = usage;
|
|
@@ -1167,7 +1393,11 @@ export function pickLowestUsageCodexAccount(
|
|
|
1167
1393
|
quotaScope?: CodexQuotaScope,
|
|
1168
1394
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1169
1395
|
): string | null {
|
|
1170
|
-
return pickLowestUsageAmong(
|
|
1396
|
+
return pickLowestUsageAmong(
|
|
1397
|
+
config,
|
|
1398
|
+
getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions),
|
|
1399
|
+
selectionOptions,
|
|
1400
|
+
);
|
|
1171
1401
|
}
|
|
1172
1402
|
|
|
1173
1403
|
/**
|
|
@@ -1307,12 +1537,20 @@ function pickPriorityPreemption(
|
|
|
1307
1537
|
// A live pin already lowered the tier ceiling; never preempt past an explicit
|
|
1308
1538
|
// operator choice. Same liveness test the tier filter applies, so preview and
|
|
1309
1539
|
// resolve agree even before the pin is garbage-collected.
|
|
1310
|
-
if (
|
|
1540
|
+
if (
|
|
1541
|
+
pinned !== undefined
|
|
1542
|
+
&& eligible.includes(pinned)
|
|
1543
|
+
&& hasCodexQuotaHeadroom(config, pinned, selectionOptions)
|
|
1544
|
+
) return null;
|
|
1311
1545
|
const priorityOf = codexAccountPriorityLookup(config);
|
|
1312
1546
|
if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
|
|
1313
1547
|
// Members without headroom are in the tier only because a sibling has some;
|
|
1314
1548
|
// picking one would hand the request straight back to a drained account.
|
|
1315
|
-
return pickLowestUsageAmong(
|
|
1549
|
+
return pickLowestUsageAmong(
|
|
1550
|
+
config,
|
|
1551
|
+
eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions)),
|
|
1552
|
+
selectionOptions,
|
|
1553
|
+
);
|
|
1316
1554
|
}
|
|
1317
1555
|
|
|
1318
1556
|
/**
|
|
@@ -1322,13 +1560,27 @@ function pickPriorityPreemption(
|
|
|
1322
1560
|
* on its own. Clearing the pin also removes the condition, so this writes at
|
|
1323
1561
|
* most once per pin.
|
|
1324
1562
|
*/
|
|
1325
|
-
function releaseDrainedCodexAccountPin(
|
|
1563
|
+
function releaseDrainedCodexAccountPin(
|
|
1564
|
+
config: OcxConfig,
|
|
1565
|
+
selectionOptions?: Pick<
|
|
1566
|
+
CodexAccountUsabilityOptions,
|
|
1567
|
+
"nativeMainSelectionOnly" | "isMainAccountTokenLive"
|
|
1568
|
+
>,
|
|
1569
|
+
): void {
|
|
1326
1570
|
const pinned = pinnedCodexAccountId(config);
|
|
1327
1571
|
if (pinned === undefined) return;
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1572
|
+
const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned);
|
|
1573
|
+
if (knownUnavailable) {
|
|
1574
|
+
clearCodexAccountPin(config);
|
|
1575
|
+
saveConfigPreservingClaudeCode(config);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
// Temporary drain deliberately forbids every native-main read. A pin on main
|
|
1579
|
+
// cannot be classified by credential liveness or quota until the fenced profile
|
|
1580
|
+
// is readable. Cached reauth and configured pause state were handled above.
|
|
1581
|
+
if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
|
|
1582
|
+
const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
|
|
1583
|
+
|| !hasCodexQuotaHeadroom(config, pinned, selectionOptions);
|
|
1332
1584
|
if (!drained) return;
|
|
1333
1585
|
clearCodexAccountPin(config);
|
|
1334
1586
|
saveConfigPreservingClaudeCode(config);
|
|
@@ -1340,18 +1592,24 @@ function applyQuotaAutoSwitch(
|
|
|
1340
1592
|
now: number,
|
|
1341
1593
|
quotaScope?: CodexQuotaScope,
|
|
1342
1594
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1595
|
+
commitSharedSelection = true,
|
|
1343
1596
|
): string {
|
|
1344
1597
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1345
1598
|
if (threshold <= 0) return active;
|
|
1346
1599
|
const quota = getAccountQuota(active);
|
|
1347
|
-
const activeUsage = computeCodexUsageScore(
|
|
1600
|
+
const activeUsage = computeCodexUsageScore(
|
|
1601
|
+
quota,
|
|
1602
|
+
getPoolAccountPlanForSelection(config, active, selectionOptions),
|
|
1603
|
+
);
|
|
1348
1604
|
// Unknown usage is not evidence that a user's explicit selection crossed the
|
|
1349
1605
|
// threshold. Wait for quota priming instead of rotating among guesses.
|
|
1350
1606
|
if (isUnknownUsage(activeUsage)) return active;
|
|
1351
1607
|
if (activeUsage < threshold) return active;
|
|
1352
1608
|
const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
|
|
1353
1609
|
if (best !== active) {
|
|
1354
|
-
if (!isIndependentCodexQuotaScope(quotaScope))
|
|
1610
|
+
if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
|
|
1611
|
+
setActiveCodexAccount(config, best);
|
|
1612
|
+
}
|
|
1355
1613
|
return best;
|
|
1356
1614
|
}
|
|
1357
1615
|
|
|
@@ -1361,17 +1619,55 @@ function applyQuotaAutoSwitch(
|
|
|
1361
1619
|
function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
|
|
1362
1620
|
const threshold = config.upstreamFailoverThreshold ?? 3;
|
|
1363
1621
|
if (threshold <= 0) return false;
|
|
1622
|
+
dropSpentCredentialFailure(accountId);
|
|
1364
1623
|
const health = upstreamHealth.get(accountId);
|
|
1365
1624
|
if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
|
|
1366
1625
|
return !!health && health.consecutiveFailures >= threshold;
|
|
1367
1626
|
}
|
|
1368
1627
|
|
|
1628
|
+
function isHealthySharedCodexSelection(
|
|
1629
|
+
config: OcxConfig,
|
|
1630
|
+
accountId: string,
|
|
1631
|
+
now: number,
|
|
1632
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
1633
|
+
selectionOptions: CodexAccountUsabilityOptions | undefined,
|
|
1634
|
+
): boolean {
|
|
1635
|
+
return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
|
|
1636
|
+
&& hasCodexQuotaHeadroom(config, accountId, selectionOptions)
|
|
1637
|
+
&& !shouldFailover(config, accountId, now);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function strategySelectionOptionsForModelDetour(
|
|
1641
|
+
config: OcxConfig,
|
|
1642
|
+
now: number,
|
|
1643
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
1644
|
+
selectionOptions: CodexAccountUsabilityOptions | undefined,
|
|
1645
|
+
): CodexAccountUsabilityOptions | undefined {
|
|
1646
|
+
if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions;
|
|
1647
|
+
const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {};
|
|
1648
|
+
return {
|
|
1649
|
+
...selectionOptions,
|
|
1650
|
+
modelEligibleAccountIds: new Set(
|
|
1651
|
+
[...selectionOptions.modelEligibleAccountIds].filter(accountId =>
|
|
1652
|
+
isHealthySharedCodexSelection(
|
|
1653
|
+
config,
|
|
1654
|
+
accountId,
|
|
1655
|
+
now,
|
|
1656
|
+
quotaScope,
|
|
1657
|
+
sharedSelectionOptions,
|
|
1658
|
+
)
|
|
1659
|
+
),
|
|
1660
|
+
),
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1369
1664
|
function applyFailureFailover(
|
|
1370
1665
|
config: OcxConfig,
|
|
1371
1666
|
active: string,
|
|
1372
1667
|
now: number,
|
|
1373
1668
|
quotaScope?: CodexQuotaScope,
|
|
1374
1669
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1670
|
+
commitSharedSelection = true,
|
|
1375
1671
|
): string {
|
|
1376
1672
|
if (!shouldFailover(config, active, now)) return active;
|
|
1377
1673
|
const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
@@ -1382,7 +1678,9 @@ function applyFailureFailover(
|
|
|
1382
1678
|
// the moment of the failure; the streak outlives the soft avoid, so a later
|
|
1383
1679
|
// scoped resolve reaches here with the streak still tripped and would otherwise
|
|
1384
1680
|
// move the shared cursor after all.
|
|
1385
|
-
if (!isIndependentCodexQuotaScope(quotaScope))
|
|
1681
|
+
if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
|
|
1682
|
+
promoteActiveCodexAccount(config, best);
|
|
1683
|
+
}
|
|
1386
1684
|
return best;
|
|
1387
1685
|
}
|
|
1388
1686
|
return active;
|
|
@@ -1398,6 +1696,88 @@ export function resolveCodexAccountForThread(
|
|
|
1398
1696
|
return resolution.status === "selected" ? resolution.accountId : null;
|
|
1399
1697
|
}
|
|
1400
1698
|
|
|
1699
|
+
function previewReusableAffinityAccount(
|
|
1700
|
+
entry: ThreadAffinityEntry | undefined,
|
|
1701
|
+
config: OcxConfig,
|
|
1702
|
+
now: number,
|
|
1703
|
+
quotaScope?: CodexQuotaScope,
|
|
1704
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1705
|
+
): string | null {
|
|
1706
|
+
if (
|
|
1707
|
+
!entry
|
|
1708
|
+
|| isThreadAffinityExpired(entry, now)
|
|
1709
|
+
|| !isThreadAffinityGenerationLive(entry)
|
|
1710
|
+
|| !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
|
|
1711
|
+
|| shouldFailover(config, entry.accountId, now)
|
|
1712
|
+
) {
|
|
1713
|
+
return null;
|
|
1714
|
+
}
|
|
1715
|
+
// Quota strategy only: non-quota strategies keep affinity for ongoing threads
|
|
1716
|
+
// (new-session-only rotation — docs / affinity policy A).
|
|
1717
|
+
if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
|
|
1718
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1719
|
+
if (threshold > 0) {
|
|
1720
|
+
const usage = computeCodexUsageScore(
|
|
1721
|
+
getAccountQuota(entry.accountId),
|
|
1722
|
+
getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
|
|
1723
|
+
);
|
|
1724
|
+
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1725
|
+
const best = pickLowerUsageAccount(
|
|
1726
|
+
config,
|
|
1727
|
+
entry.accountId,
|
|
1728
|
+
usage,
|
|
1729
|
+
now,
|
|
1730
|
+
quotaScope,
|
|
1731
|
+
selectionOptions,
|
|
1732
|
+
true,
|
|
1733
|
+
);
|
|
1734
|
+
if (best !== entry.accountId) return best;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return entry.accountId;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* Re-evaluate an affined account under the quota strategy. Returns a strictly
|
|
1743
|
+
* cooler replacement, or null when the current binding should remain.
|
|
1744
|
+
*/
|
|
1745
|
+
function reevaluateAffinityQuota(
|
|
1746
|
+
entry: ThreadAffinityEntry,
|
|
1747
|
+
config: OcxConfig,
|
|
1748
|
+
now: number,
|
|
1749
|
+
quotaScope?: CodexQuotaScope,
|
|
1750
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1751
|
+
): string | null {
|
|
1752
|
+
if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null;
|
|
1753
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1754
|
+
const usage = threshold > 0
|
|
1755
|
+
? computeCodexUsageScore(
|
|
1756
|
+
getAccountQuota(entry.accountId),
|
|
1757
|
+
getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
|
|
1758
|
+
)
|
|
1759
|
+
: 0;
|
|
1760
|
+
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
1761
|
+
if (
|
|
1762
|
+
!overThreshold
|
|
1763
|
+
&& now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS
|
|
1764
|
+
) {
|
|
1765
|
+
return null;
|
|
1766
|
+
}
|
|
1767
|
+
entry.lastReevalAt = now;
|
|
1768
|
+
if (!overThreshold) return null;
|
|
1769
|
+
const best = pickLowerUsageAccount(
|
|
1770
|
+
config,
|
|
1771
|
+
entry.accountId,
|
|
1772
|
+
usage,
|
|
1773
|
+
now,
|
|
1774
|
+
quotaScope,
|
|
1775
|
+
selectionOptions,
|
|
1776
|
+
true,
|
|
1777
|
+
);
|
|
1778
|
+
return best === entry.accountId ? null : best;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1401
1781
|
/**
|
|
1402
1782
|
* Side-effect-free preview of the Codex pool account native routing would prefer.
|
|
1403
1783
|
* Used for subagent fallback quota decisions before final auth.
|
|
@@ -1412,42 +1792,31 @@ export function previewCodexAccountForRequest(
|
|
|
1412
1792
|
now = Date.now(),
|
|
1413
1793
|
quotaScope?: CodexQuotaScope,
|
|
1414
1794
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1795
|
+
modelId?: string,
|
|
1415
1796
|
): string | null {
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
if (threshold > 0) {
|
|
1430
|
-
const usage = computeCodexUsageScore(
|
|
1431
|
-
getAccountQuota(entry.accountId),
|
|
1432
|
-
getPoolAccountPlan(config, entry.accountId),
|
|
1433
|
-
);
|
|
1434
|
-
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1435
|
-
const best = pickLowerUsageAccount(
|
|
1436
|
-
config,
|
|
1437
|
-
entry.accountId,
|
|
1438
|
-
usage,
|
|
1439
|
-
now,
|
|
1440
|
-
quotaScope,
|
|
1441
|
-
selectionOptions,
|
|
1442
|
-
);
|
|
1443
|
-
if (best !== entry.accountId) return best;
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
return entry.accountId;
|
|
1448
|
-
}
|
|
1449
|
-
// Stale/unusable affinity is ignored for preview (no map mutation).
|
|
1797
|
+
// A request-scoped model detour keeps its own serving-account affinity. Preview
|
|
1798
|
+
// reads it before the ordinary lane, but never repairs or deletes it. Roster
|
|
1799
|
+
// expansion therefore preserves the already-serving account, and preview mirrors
|
|
1800
|
+
// final resolution even when the ordinary lane was independently retired.
|
|
1801
|
+
if (threadId && selectionOptions?.modelEligibleAccountIds !== undefined) {
|
|
1802
|
+
const detourPreview = previewReusableAffinityAccount(
|
|
1803
|
+
getModelDetourAffinity(threadId, modelId, quotaScope),
|
|
1804
|
+
config,
|
|
1805
|
+
now,
|
|
1806
|
+
quotaScope,
|
|
1807
|
+
selectionOptions,
|
|
1808
|
+
);
|
|
1809
|
+
if (detourPreview) return detourPreview;
|
|
1450
1810
|
}
|
|
1811
|
+
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1812
|
+
const ordinaryPreview = previewReusableAffinityAccount(
|
|
1813
|
+
entry,
|
|
1814
|
+
config,
|
|
1815
|
+
now,
|
|
1816
|
+
quotaScope,
|
|
1817
|
+
selectionOptions,
|
|
1818
|
+
);
|
|
1819
|
+
if (ordinaryPreview) return ordinaryPreview;
|
|
1451
1820
|
|
|
1452
1821
|
const strategyPick = pickUnboundStrategyAccount(
|
|
1453
1822
|
config,
|
|
@@ -1455,7 +1824,7 @@ export function previewCodexAccountForRequest(
|
|
|
1455
1824
|
now,
|
|
1456
1825
|
false,
|
|
1457
1826
|
quotaScope,
|
|
1458
|
-
selectionOptions,
|
|
1827
|
+
strategySelectionOptionsForModelDetour(config, now, quotaScope, selectionOptions),
|
|
1459
1828
|
);
|
|
1460
1829
|
if (strategyPick) return strategyPick;
|
|
1461
1830
|
|
|
@@ -1476,7 +1845,10 @@ export function previewCodexAccountForRequest(
|
|
|
1476
1845
|
|
|
1477
1846
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1478
1847
|
if (threshold > 0) {
|
|
1479
|
-
const usage = computeCodexUsageScore(
|
|
1848
|
+
const usage = computeCodexUsageScore(
|
|
1849
|
+
getAccountQuota(active),
|
|
1850
|
+
getPoolAccountPlanForSelection(config, active, selectionOptions),
|
|
1851
|
+
);
|
|
1480
1852
|
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1481
1853
|
active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
|
|
1482
1854
|
}
|
|
@@ -1501,12 +1873,64 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1501
1873
|
now = Date.now(),
|
|
1502
1874
|
quotaScope?: CodexQuotaScope,
|
|
1503
1875
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1876
|
+
modelId?: string,
|
|
1504
1877
|
): CodexThreadResolution {
|
|
1878
|
+
// An entitlement roster constrains only this model request. It must not rewrite
|
|
1879
|
+
// the operator's shared active/pin choice or the task's ordinary-model affinity.
|
|
1880
|
+
const modelScopedSelection = selectionOptions?.modelEligibleAccountIds !== undefined;
|
|
1881
|
+
let preserveExistingModelScopedAffinity = false;
|
|
1882
|
+
const sharedSelectionOptions: CodexAccountUsabilityOptions | undefined = modelScopedSelection
|
|
1883
|
+
? sharedStateSelectionOptions(selectionOptions) ?? {}
|
|
1884
|
+
: selectionOptions;
|
|
1505
1885
|
// Retiring a spent manual pin is independent of affinity: an existing thread
|
|
1506
1886
|
// keeps its account below, but the operator's tier ceiling must not silently
|
|
1507
1887
|
// revive after quota resets. Independent model scopes must never persist a
|
|
1508
1888
|
// change to shared routing state.
|
|
1509
|
-
if (!isIndependentCodexQuotaScope(quotaScope))
|
|
1889
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) {
|
|
1890
|
+
releaseDrainedCodexAccountPin(config, sharedStateSelectionOptions(selectionOptions));
|
|
1891
|
+
}
|
|
1892
|
+
const sharedActiveBeforeSelection = getEffectiveActiveCodexAccountId(config);
|
|
1893
|
+
const preserveSharedSelectionForModelDetour = modelScopedSelection && (
|
|
1894
|
+
sharedActiveBeforeSelection === undefined
|
|
1895
|
+
|| isHealthySharedCodexSelection(
|
|
1896
|
+
config,
|
|
1897
|
+
sharedActiveBeforeSelection,
|
|
1898
|
+
now,
|
|
1899
|
+
quotaScope,
|
|
1900
|
+
sharedSelectionOptions,
|
|
1901
|
+
)
|
|
1902
|
+
);
|
|
1903
|
+
|
|
1904
|
+
if (threadId && modelScopedSelection) {
|
|
1905
|
+
const detourEntry = getModelDetourAffinity(threadId, modelId, quotaScope);
|
|
1906
|
+
if (detourEntry) {
|
|
1907
|
+
const detourReusable = !isThreadAffinityExpired(detourEntry, now)
|
|
1908
|
+
&& isThreadAffinityGenerationLive(detourEntry)
|
|
1909
|
+
&& isCodexAccountSelectable(config, detourEntry.accountId, now, quotaScope, selectionOptions)
|
|
1910
|
+
&& !shouldFailover(config, detourEntry.accountId, now);
|
|
1911
|
+
if (detourReusable) {
|
|
1912
|
+
detourEntry.lastUsedAt = now;
|
|
1913
|
+
// Model detours follow the same affinity policy as ordinary bindings:
|
|
1914
|
+
// RR/fill-first stay sticky, while quota strategy may re-evaluate an
|
|
1915
|
+
// over-threshold account without changing the ordinary lane.
|
|
1916
|
+
const cooler = reevaluateAffinityQuota(
|
|
1917
|
+
detourEntry,
|
|
1918
|
+
config,
|
|
1919
|
+
now,
|
|
1920
|
+
quotaScope,
|
|
1921
|
+
selectionOptions,
|
|
1922
|
+
);
|
|
1923
|
+
if (cooler) {
|
|
1924
|
+
bindModelDetourAffinity(threadId, cooler, now, modelId, quotaScope);
|
|
1925
|
+
return { status: "selected", accountId: cooler };
|
|
1926
|
+
}
|
|
1927
|
+
return { status: "selected", accountId: detourEntry.accountId };
|
|
1928
|
+
}
|
|
1929
|
+
// Detour expiry or invalidation must not expire the ordinary task. Drop only
|
|
1930
|
+
// this model lane and select from ordinary/shared state below.
|
|
1931
|
+
deleteModelDetourAffinity(threadId, modelId, quotaScope);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1510
1934
|
|
|
1511
1935
|
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1512
1936
|
if (threadId && entry) {
|
|
@@ -1514,12 +1938,20 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1514
1938
|
deleteThreadAffinity(threadId, quotaScope);
|
|
1515
1939
|
return { status: "expired", accountId: entry.accountId };
|
|
1516
1940
|
}
|
|
1941
|
+
const generationLive = isThreadAffinityGenerationLive(entry);
|
|
1942
|
+
const selectableForSharedState = generationLive
|
|
1943
|
+
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope, sharedSelectionOptions);
|
|
1944
|
+
const selectableForRequest = selectableForSharedState
|
|
1945
|
+
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions);
|
|
1946
|
+
const failoverReady = shouldFailover(config, entry.accountId, now);
|
|
1947
|
+
const healthyForSharedAffinity = selectableForSharedState
|
|
1948
|
+
&& hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions)
|
|
1949
|
+
&& !failoverReady;
|
|
1517
1950
|
if (
|
|
1518
|
-
|
|
1519
|
-
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
|
|
1951
|
+
selectableForRequest
|
|
1520
1952
|
// Affined threads must leave a failing account once the streak trips failover
|
|
1521
1953
|
// (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
|
|
1522
|
-
&& !
|
|
1954
|
+
&& !failoverReady
|
|
1523
1955
|
) {
|
|
1524
1956
|
entry.lastUsedAt = now;
|
|
1525
1957
|
// Periodic quota re-eval: a long-lived bound thread must still switch when
|
|
@@ -1530,48 +1962,107 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1530
1962
|
// serving for up to 60s after a secondary with quota is available (#584).
|
|
1531
1963
|
// Non-quota strategies (RR / fill-first) keep affinity for ongoing threads —
|
|
1532
1964
|
// rotation is new-session-only (affinity policy A).
|
|
1533
|
-
const
|
|
1534
|
-
if (
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
? computeCodexUsageScore(
|
|
1538
|
-
getAccountQuota(entry.accountId),
|
|
1539
|
-
getPoolAccountPlan(config, entry.accountId),
|
|
1540
|
-
)
|
|
1541
|
-
: 0;
|
|
1542
|
-
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
1543
|
-
if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
|
|
1544
|
-
entry.lastReevalAt = now;
|
|
1545
|
-
if (overThreshold) {
|
|
1546
|
-
const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions);
|
|
1547
|
-
if (best !== entry.accountId) {
|
|
1548
|
-
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
|
|
1549
|
-
bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
|
|
1550
|
-
return { status: "selected", accountId: best };
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1965
|
+
const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions);
|
|
1966
|
+
if (cooler) {
|
|
1967
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) {
|
|
1968
|
+
setActiveCodexAccount(config, cooler);
|
|
1553
1969
|
}
|
|
1970
|
+
bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
|
|
1971
|
+
return { status: "selected", accountId: cooler };
|
|
1554
1972
|
}
|
|
1555
1973
|
return { status: "selected", accountId: entry.accountId };
|
|
1556
1974
|
}
|
|
1557
|
-
|
|
1975
|
+
// A model-only exclusion does not invalidate the shared task binding. Health,
|
|
1976
|
+
// generation, pause, cooldown, and failure evidence still retire it normally.
|
|
1977
|
+
if (!modelScopedSelection || !healthyForSharedAffinity) {
|
|
1978
|
+
deleteThreadAffinity(threadId, quotaScope);
|
|
1979
|
+
} else {
|
|
1980
|
+
preserveExistingModelScopedAffinity = true;
|
|
1981
|
+
}
|
|
1558
1982
|
}
|
|
1559
1983
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1984
|
+
// A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return
|
|
1985
|
+
// before the quota/failover helpers below, so prefer only shared-healthy roster members here;
|
|
1986
|
+
// otherwise RR/fill-first can immediately re-pick a known failing account even when another
|
|
1987
|
+
// entitled account is healthy. If no healthy member exists, the normal fallback path below
|
|
1988
|
+
// still decides whether the sole eligible candidate must be used.
|
|
1989
|
+
const strategySelectionOptions = strategySelectionOptionsForModelDetour(
|
|
1990
|
+
config,
|
|
1991
|
+
now,
|
|
1992
|
+
quotaScope,
|
|
1993
|
+
selectionOptions,
|
|
1994
|
+
);
|
|
1995
|
+
const strategyPick = pickUnboundStrategyAccount(
|
|
1996
|
+
config,
|
|
1997
|
+
threadId,
|
|
1998
|
+
now,
|
|
1999
|
+
true,
|
|
2000
|
+
quotaScope,
|
|
2001
|
+
strategySelectionOptions,
|
|
2002
|
+
!modelScopedSelection,
|
|
2003
|
+
!preserveExistingModelScopedAffinity,
|
|
2004
|
+
);
|
|
2005
|
+
if (strategyPick) {
|
|
2006
|
+
if (threadId && preserveExistingModelScopedAffinity) {
|
|
2007
|
+
bindModelDetourAffinity(threadId, strategyPick, now, modelId, quotaScope);
|
|
2008
|
+
}
|
|
2009
|
+
if (
|
|
2010
|
+
modelScopedSelection
|
|
2011
|
+
&& !preserveSharedSelectionForModelDetour
|
|
2012
|
+
&& !isIndependentCodexQuotaScope(quotaScope)
|
|
2013
|
+
) {
|
|
2014
|
+
promoteActiveCodexAccount(config, strategyPick);
|
|
2015
|
+
}
|
|
2016
|
+
return { status: "selected", accountId: strategyPick };
|
|
2017
|
+
}
|
|
1562
2018
|
|
|
1563
2019
|
let active = getEffectiveActiveCodexAccountId(config);
|
|
1564
2020
|
if (!active) {
|
|
1565
2021
|
const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
|
|
1566
|
-
if (!selected)
|
|
1567
|
-
|
|
2022
|
+
if (!selected) {
|
|
2023
|
+
if (
|
|
2024
|
+
selectionOptions?.nativeMainSelectionOnly === true
|
|
2025
|
+
&& selectionOptions.modelEligibleAccountIds !== undefined
|
|
2026
|
+
) {
|
|
2027
|
+
return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID };
|
|
2028
|
+
}
|
|
2029
|
+
return { status: "none" };
|
|
2030
|
+
}
|
|
2031
|
+
if (!isIndependentCodexQuotaScope(quotaScope) && !modelScopedSelection) {
|
|
2032
|
+
setActiveCodexAccount(config, selected);
|
|
2033
|
+
}
|
|
1568
2034
|
active = selected;
|
|
1569
2035
|
}
|
|
2036
|
+
const activeSelectableForSharedState = isCodexAccountSelectable(
|
|
2037
|
+
config,
|
|
2038
|
+
active,
|
|
2039
|
+
now,
|
|
2040
|
+
quotaScope,
|
|
2041
|
+
sharedSelectionOptions,
|
|
2042
|
+
);
|
|
2043
|
+
const activeHealthyForSharedSelection = activeSelectableForSharedState
|
|
2044
|
+
&& hasCodexQuotaHeadroom(config, active, sharedSelectionOptions)
|
|
2045
|
+
&& !shouldFailover(config, active, now);
|
|
1570
2046
|
if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
|
|
1571
2047
|
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
1572
2048
|
if (fallback) {
|
|
1573
|
-
|
|
2049
|
+
const modelOnlyMove = modelScopedSelection
|
|
2050
|
+
&& preserveSharedSelectionForModelDetour
|
|
2051
|
+
&& activeHealthyForSharedSelection;
|
|
2052
|
+
if (!isIndependentCodexQuotaScope(quotaScope) && !modelOnlyMove) {
|
|
2053
|
+
setActiveCodexAccount(config, fallback);
|
|
2054
|
+
}
|
|
1574
2055
|
active = fallback;
|
|
2056
|
+
} else if (
|
|
2057
|
+
selectionOptions?.nativeMainSelectionOnly === true
|
|
2058
|
+
&& selectionOptions.modelEligibleAccountIds !== undefined
|
|
2059
|
+
) {
|
|
2060
|
+
// Entitlement discovery intentionally excludes main while a temporary drain
|
|
2061
|
+
// fences its credential. Once every eligible non-main candidate is unavailable,
|
|
2062
|
+
// return main only as a non-mutating sentinel so the caller's atomic claim can
|
|
2063
|
+
// classify maintenance. Do not fall through to the configured-but-ineligible
|
|
2064
|
+
// active account or persist/bind this synthetic selection.
|
|
2065
|
+
return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID };
|
|
1575
2066
|
} else if (
|
|
1576
2067
|
hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1577
2068
|
&& !isCodexAccountPaused(config, active)
|
|
@@ -1589,11 +2080,30 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1589
2080
|
// stays the operator's selection and getEffectiveActiveCodexAccountId is what
|
|
1590
2081
|
// surfaces this to the API and dashboard. An independent quota group must not
|
|
1591
2082
|
// move the shared cursor at all — its ordering decision is its own.
|
|
1592
|
-
if (
|
|
2083
|
+
if (
|
|
2084
|
+
!preserveSharedSelectionForModelDetour
|
|
2085
|
+
&& !isIndependentCodexQuotaScope(quotaScope)
|
|
2086
|
+
) {
|
|
2087
|
+
rememberActiveCodexAccount(config, preempted);
|
|
2088
|
+
}
|
|
1593
2089
|
active = preempted;
|
|
1594
2090
|
}
|
|
1595
|
-
active = applyQuotaAutoSwitch(
|
|
1596
|
-
|
|
2091
|
+
active = applyQuotaAutoSwitch(
|
|
2092
|
+
config,
|
|
2093
|
+
active,
|
|
2094
|
+
now,
|
|
2095
|
+
quotaScope,
|
|
2096
|
+
selectionOptions,
|
|
2097
|
+
!preserveSharedSelectionForModelDetour,
|
|
2098
|
+
);
|
|
2099
|
+
active = applyFailureFailover(
|
|
2100
|
+
config,
|
|
2101
|
+
active,
|
|
2102
|
+
now,
|
|
2103
|
+
quotaScope,
|
|
2104
|
+
selectionOptions,
|
|
2105
|
+
!preserveSharedSelectionForModelDetour,
|
|
2106
|
+
);
|
|
1597
2107
|
if (!isCodexAccountUsable(config, active, selectionOptions)) {
|
|
1598
2108
|
return hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1599
2109
|
? { status: "selected", accountId: active }
|
|
@@ -1605,7 +2115,13 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1605
2115
|
? { status: "selected", accountId: active }
|
|
1606
2116
|
: { status: "none" };
|
|
1607
2117
|
}
|
|
1608
|
-
if (threadId)
|
|
2118
|
+
if (threadId) {
|
|
2119
|
+
if (preserveExistingModelScopedAffinity) {
|
|
2120
|
+
bindModelDetourAffinity(threadId, active, now, modelId, quotaScope);
|
|
2121
|
+
} else {
|
|
2122
|
+
bindThreadAffinity(threadId, active, now, quotaScope);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
1609
2125
|
return { status: "selected", accountId: active };
|
|
1610
2126
|
}
|
|
1611
2127
|
|
|
@@ -1628,6 +2144,16 @@ export function recordCodexUpstreamOutcome(
|
|
|
1628
2144
|
const now = meta.now ?? Date.now();
|
|
1629
2145
|
const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
|
|
1630
2146
|
const quotaScope = codexQuotaScopeForModel(meta.modelId);
|
|
2147
|
+
/*
|
|
2148
|
+
* Spend a stale credential failure BEFORE any branch reads health (#2892 gap 4 review).
|
|
2149
|
+
*
|
|
2150
|
+
* Reader-side spending alone is not enough: the transient and workspace branches derive their new
|
|
2151
|
+
* entry from the current one, so a spent G1 401 would donate its `consecutiveFailures` to G2's
|
|
2152
|
+
* first genuine 503 and drop the tag while doing it. The account then reaches the failover
|
|
2153
|
+
* threshold one failure early, and no later read can tell. Clearing it here means every branch
|
|
2154
|
+
* starts from evidence that still describes a live credential.
|
|
2155
|
+
*/
|
|
2156
|
+
dropSpentCredentialFailure(accountId);
|
|
1631
2157
|
if (outcomeClass === "success") {
|
|
1632
2158
|
const scopedProbe = meta.probeQuotaScope
|
|
1633
2159
|
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
@@ -1721,13 +2247,48 @@ export function recordCodexUpstreamOutcome(
|
|
|
1721
2247
|
if (outcomeClass === "credential") {
|
|
1722
2248
|
// 401/403 quarantines the account for reauth. That supersedes quota state
|
|
1723
2249
|
// entirely: a cooldown (and any probe lease) on an unusable account is moot.
|
|
2250
|
+
// Unless the rejected credential is already gone: a stale 401 racing a
|
|
2251
|
+
// replacement would otherwise take the fresh credential out of rotation and
|
|
2252
|
+
// sweep affinities that belong to it (#2887).
|
|
2253
|
+
if (
|
|
2254
|
+
meta.credentialGeneration !== undefined
|
|
2255
|
+
&& !isCodexAccountGenerationLive(accountId, meta.credentialGeneration)
|
|
2256
|
+
) {
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
/*
|
|
2260
|
+
* The pre-check above closes the same-process race, but not a cross-process one (#2892 gap 4).
|
|
2261
|
+
* `isCodexAccountGenerationLive` is an unlocked read while credential writers coordinate under
|
|
2262
|
+
* the mutation lock, and OS preemption needs no `await` — so another process can replace the
|
|
2263
|
+
* credential after this check, or at any point after this whole function returns. No re-read here
|
|
2264
|
+
* can close that: a replacement is always free to land one instruction later.
|
|
2265
|
+
*
|
|
2266
|
+
* Taking the credential lock is not an option either: it runs with `busy_timeout=0`, so acquiring
|
|
2267
|
+
* it per outcome would turn ordinary contention into thrown request-path errors.
|
|
2268
|
+
*
|
|
2269
|
+
* So the evidence is TAGGED with the credential it describes and judged when it is READ. The
|
|
2270
|
+
* health entry carries `credentialFailureGeneration` and the reauth map carries the same
|
|
2271
|
+
* generation; `dropSpentCredentialFailure` and `isAccountNeedsReauth` discard an entry whose
|
|
2272
|
+
* credential is gone. A later transient or quota write replaces the entry along with its tag, and
|
|
2273
|
+
* `preservedCooldownFields` drops the tag explicitly, so this provenance can never be spent
|
|
2274
|
+
* against a failure it did not describe.
|
|
2275
|
+
*
|
|
2276
|
+
* Affinity sweeping needs no tag: an affinity entry already carries a credential generation and
|
|
2277
|
+
* self-invalidates on the next check, and re-adding swept entries would be a worse bug.
|
|
2278
|
+
*/
|
|
1724
2279
|
upstreamHealth.set(accountId, {
|
|
1725
2280
|
consecutiveFailures: 1,
|
|
1726
2281
|
lastFailureStatus,
|
|
1727
2282
|
lastFailureAt: now,
|
|
2283
|
+
// Provenance rides on the entry: only this failure can be spent when its credential dies.
|
|
2284
|
+
...(meta.credentialGeneration !== undefined
|
|
2285
|
+
? { credentialFailureGeneration: meta.credentialGeneration }
|
|
2286
|
+
: {}),
|
|
1728
2287
|
});
|
|
1729
2288
|
quotaScopedHealth.delete(accountId);
|
|
1730
|
-
|
|
2289
|
+
// The reauth flag carries the same provenance, so a replacement landing after this call cannot
|
|
2290
|
+
// inherit a quarantine that was never about it.
|
|
2291
|
+
markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration);
|
|
1731
2292
|
clearThreadAccountMapForAccount(accountId);
|
|
1732
2293
|
return;
|
|
1733
2294
|
}
|