@bitkyc08/opencodex 2.55.0 → 2.56.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-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { isCodexAccountPaused } from "../account-pause";
|
|
2
|
+
import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
|
|
3
|
+
import { isSelectableCodexPoolAccount } from "../account-id";
|
|
4
|
+
import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
5
|
+
import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "../account-usability";
|
|
6
|
+
import { isCodexPoolRefreshCooling } from "../pool-refresh-backoff";
|
|
7
|
+
import {
|
|
8
|
+
normalizeAccountPoolStickyLimit,
|
|
9
|
+
normalizeCodexAccountPoolStrategy,
|
|
10
|
+
notePoolRotationSuccess,
|
|
11
|
+
peekRoundRobinAccount,
|
|
12
|
+
pickRoundRobinAccount,
|
|
13
|
+
selectPriorityTier,
|
|
14
|
+
} from "../pool-rotation";
|
|
15
|
+
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "../quota";
|
|
16
|
+
import { codexPlanKey } from "../plan";
|
|
17
|
+
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan, hasMainAccountRefreshGrant } from "../main-account";
|
|
18
|
+
import type { OcxConfig } from "../../types";
|
|
19
|
+
import { CODEX_FAILURE_WINDOW_MS, computeCodexUsageScore } from "./cooldown-math";
|
|
20
|
+
import {
|
|
21
|
+
codexPoolKeyForScope,
|
|
22
|
+
dropSpentCredentialFailure,
|
|
23
|
+
getAccountHealth,
|
|
24
|
+
getCodexQuotaHealthSnapshot,
|
|
25
|
+
isCodexAccountSoftAvoided,
|
|
26
|
+
isCodexQuotaAvoided,
|
|
27
|
+
isIndependentCodexQuotaScope,
|
|
28
|
+
type CodexQuotaScope,
|
|
29
|
+
} from "./health-store";
|
|
30
|
+
import { bindThreadAffinity, type CodexAffinityReason } from "./thread-affinity";
|
|
31
|
+
import {
|
|
32
|
+
getEffectiveActiveCodexAccountId,
|
|
33
|
+
manualPreferenceBlocks,
|
|
34
|
+
promoteActiveCodexAccount,
|
|
35
|
+
rememberActiveCodexAccount,
|
|
36
|
+
setActiveCodexAccount,
|
|
37
|
+
} from "./active-account";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an
|
|
41
|
+
* existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan
|
|
42
|
+
* is an unrestricted provider string whose casing this repository does not control.
|
|
43
|
+
*/
|
|
44
|
+
function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet<string> | undefined {
|
|
45
|
+
const configured = config.codexPool?.excludedPlans;
|
|
46
|
+
if (!configured?.length) return undefined;
|
|
47
|
+
const keys = configured
|
|
48
|
+
.map(plan => codexPlanKey(plan))
|
|
49
|
+
.filter((key): key is string => key !== undefined);
|
|
50
|
+
return keys.length > 0 ? new Set(keys) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether the operator's plan policy removes this account from automatic selection.
|
|
55
|
+
*
|
|
56
|
+
* Modelled on pause rather than usability: an excluded account keeps its credential, quota history,
|
|
57
|
+
* and affinity, stays visible on the account surface, and is still reachable by explicit account
|
|
58
|
+
* selection. Only automatic rotation skips it, which is the distinction #4211 asked for.
|
|
59
|
+
*
|
|
60
|
+
* It is checked in the same two places pause is checked, and that is not redundancy. The eligible
|
|
61
|
+
* list is consulted only when routing picks a NEW account; an already-active or already-affined
|
|
62
|
+
* account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves
|
|
63
|
+
* behind exactly that account, so a policy that filtered only the eligible list would miss the case
|
|
64
|
+
* it exists for.
|
|
65
|
+
*
|
|
66
|
+
* `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a
|
|
67
|
+
* selection-only drain so routing never reads the fenced native credential for it, so a rule that
|
|
68
|
+
* covered main would disagree with itself between drain and ordinary routing.
|
|
69
|
+
*/
|
|
70
|
+
export function isCodexAccountPlanExcluded(
|
|
71
|
+
config: OcxConfig,
|
|
72
|
+
accountId: string,
|
|
73
|
+
precomputed?: ReadonlySet<string>,
|
|
74
|
+
): boolean {
|
|
75
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
|
|
76
|
+
// Callers that test a whole list pass the set once rather than rebuilding it per row.
|
|
77
|
+
const excluded = precomputed ?? excludedCodexPoolPlanKeys(config);
|
|
78
|
+
if (!excluded) return false;
|
|
79
|
+
const plan = codexPlanKey(getPoolAccountPlan(config, accountId));
|
|
80
|
+
return plan !== undefined && excluded.has(plan);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isCodexAccountSelectable(
|
|
84
|
+
config: OcxConfig,
|
|
85
|
+
accountId: string,
|
|
86
|
+
now: number,
|
|
87
|
+
quotaScope?: CodexQuotaScope,
|
|
88
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
89
|
+
): boolean {
|
|
90
|
+
return !isCodexAccountPaused(config, accountId)
|
|
91
|
+
&& !isCodexAccountPlanExcluded(config, accountId)
|
|
92
|
+
&& getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
|
|
93
|
+
&& !isCodexQuotaAvoided(accountId, quotaScope, now)
|
|
94
|
+
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
95
|
+
&& !isCodexPoolRefreshCooling(accountId, now)
|
|
96
|
+
&& isCodexAccountUsable(config, accountId, selectionOptions);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which guard in {@link isCodexAccountSelectable} refused this account, if any.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately the same predicates in the same order as that function, because the point is to
|
|
103
|
+
* REPORT the guard that actually fired rather than to re-derive a plausible-looking cause. An
|
|
104
|
+
* earlier version of the release reason checked only a subset and let a paused, plan-excluded,
|
|
105
|
+
* cooled-down or quota-avoided release fall through to a quota fallback, which named something
|
|
106
|
+
* routing never used -- a diagnostic that is confidently wrong in exactly the cases an operator
|
|
107
|
+
* would consult it for (#4598).
|
|
108
|
+
*/
|
|
109
|
+
export function codexAccountBlockReason(
|
|
110
|
+
config: OcxConfig,
|
|
111
|
+
accountId: string,
|
|
112
|
+
now: number,
|
|
113
|
+
quotaScope?: CodexQuotaScope,
|
|
114
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
115
|
+
): CodexAffinityReason | undefined {
|
|
116
|
+
if (isCodexAccountPaused(config, accountId)) return "paused";
|
|
117
|
+
if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded";
|
|
118
|
+
if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown";
|
|
119
|
+
if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided";
|
|
120
|
+
if (isCodexAccountSoftAvoided(accountId, now)) return "transient";
|
|
121
|
+
if (isCodexPoolRefreshCooling(accountId, now)) return "transient";
|
|
122
|
+
if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getEligiblePoolAccounts(
|
|
127
|
+
config: OcxConfig,
|
|
128
|
+
excludeId?: string,
|
|
129
|
+
now = Date.now(),
|
|
130
|
+
quotaScope?: CodexQuotaScope,
|
|
131
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
132
|
+
skipFailoverReadyCandidates = false,
|
|
133
|
+
): readonly string[] {
|
|
134
|
+
const excludedPlans = excludedCodexPoolPlanKeys(config);
|
|
135
|
+
const ids = (config.codexAccounts ?? [])
|
|
136
|
+
.filter(account => isSelectableCodexPoolAccount(account)
|
|
137
|
+
&& account.id !== excludeId
|
|
138
|
+
&& !isCodexAccountPaused(config, account.id)
|
|
139
|
+
&& !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
|
|
140
|
+
&& !isAccountNeedsReauth(account.id)
|
|
141
|
+
&& (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
|
|
142
|
+
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
|
143
|
+
.filter(account => !isCodexAccountSoftAvoided(account.id, now))
|
|
144
|
+
.filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now))
|
|
145
|
+
.filter(account => !isCodexPoolRefreshCooling(account.id, now))
|
|
146
|
+
.filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
|
|
147
|
+
.map(account => account.id);
|
|
148
|
+
// The main Codex account is not stored in config.codexAccounts; include it as a
|
|
149
|
+
// first-class rotation candidate when its read-only token is usable (Option A).
|
|
150
|
+
if (
|
|
151
|
+
excludeId !== MAIN_CODEX_ACCOUNT_ID
|
|
152
|
+
&& !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
153
|
+
&& (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
|
|
154
|
+
&& getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
|
|
155
|
+
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
156
|
+
// The main login is not in `config.codexAccounts`, so it never passes through the
|
|
157
|
+
// filters above and this is the only place an avoidance window can exclude it. Without
|
|
158
|
+
// this the window a refusal announced applies to the pool but not to the account that
|
|
159
|
+
// earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and
|
|
160
|
+
// in between the main account returns as a first-class candidate.
|
|
161
|
+
&& !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
|
|
162
|
+
&& !isCodexPoolRefreshCooling(MAIN_CODEX_ACCOUNT_ID, now)
|
|
163
|
+
&& (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
|
|
164
|
+
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
|
|
165
|
+
) {
|
|
166
|
+
ids.unshift(MAIN_CODEX_ACCOUNT_ID);
|
|
167
|
+
}
|
|
168
|
+
// Single choke point for selection order: every strategy, failover, and preview
|
|
169
|
+
// reaches the pool through here, so tiering applies once rather than per picker.
|
|
170
|
+
// Eligibility above is unchanged — this only narrows an already-eligible list.
|
|
171
|
+
return selectPriorityTier(
|
|
172
|
+
ids,
|
|
173
|
+
codexAccountPriorityLookup(config),
|
|
174
|
+
id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
|
|
175
|
+
pinnedCodexAccountId(config),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function listEligibleCodexAccountIds(
|
|
180
|
+
config: OcxConfig,
|
|
181
|
+
now: number,
|
|
182
|
+
quotaScope?: CodexQuotaScope,
|
|
183
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
184
|
+
): readonly string[] {
|
|
185
|
+
return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Shared reset timestamps are not evidence for independent model-quota groups. */
|
|
189
|
+
export function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) {
|
|
190
|
+
const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy);
|
|
191
|
+
return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stickyLimitForConfig(config: OcxConfig): number {
|
|
195
|
+
return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether an account still has quota to give under the auto-switch threshold.
|
|
200
|
+
*
|
|
201
|
+
* Fill-first and the priority tier filter share this predicate, and share both of
|
|
202
|
+
* its escape hatches. A disabled threshold means only health, pause, and reauth
|
|
203
|
+
* may drain an account; unknown usage is a guess, so it must neither force
|
|
204
|
+
* fill-first off the active account nor drain a tier that was simply never
|
|
205
|
+
* primed. A genuinely exhausted account 429s into cooldown and leaves
|
|
206
|
+
* eligibility on its own.
|
|
207
|
+
*/
|
|
208
|
+
export function hasCodexQuotaHeadroom(
|
|
209
|
+
config: OcxConfig,
|
|
210
|
+
accountId: string,
|
|
211
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
212
|
+
now: number = Date.now(),
|
|
213
|
+
): boolean {
|
|
214
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
215
|
+
if (threshold <= 0) return true;
|
|
216
|
+
const usage = computeCodexUsageScore(
|
|
217
|
+
getAccountQuota(accountId),
|
|
218
|
+
getPoolAccountPlanForSelection(config, accountId, selectionOptions),
|
|
219
|
+
now,
|
|
220
|
+
);
|
|
221
|
+
if (isUnknownUsage(usage)) return true;
|
|
222
|
+
return usage < threshold;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Is a live binding held for its prompt cache?
|
|
227
|
+
*
|
|
228
|
+
* Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured
|
|
229
|
+
* what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound
|
|
230
|
+
* conversation from account to account, and because provider prompt caches are account-isolated
|
|
231
|
+
* every hop re-sends the entire prefix. An install that has never heard of this flag is exactly
|
|
232
|
+
* the install that gets hurt by it, so the protection cannot be something you have to find.
|
|
233
|
+
*
|
|
234
|
+
* `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread
|
|
235
|
+
* on a busy account pays latency -- and it stays available; it is just no longer the default.
|
|
236
|
+
*/
|
|
237
|
+
export function isCacheAffinityEnabled(config: OcxConfig): boolean {
|
|
238
|
+
return config.pool?.cacheAffinity !== false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
|
|
242
|
+
export function pickResetFirstCodexAccount(
|
|
243
|
+
config: OcxConfig,
|
|
244
|
+
ids: readonly string[],
|
|
245
|
+
now: number,
|
|
246
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
247
|
+
): string | null {
|
|
248
|
+
const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
|
|
249
|
+
if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now);
|
|
250
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
251
|
+
let candidates: string[] = [];
|
|
252
|
+
for (const id of available) {
|
|
253
|
+
const quota = getAccountQuota(id);
|
|
254
|
+
const resets = [quota?.shortResetAt, quota?.weeklyResetAt]
|
|
255
|
+
.filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset))
|
|
256
|
+
.map(resetAtToMs)
|
|
257
|
+
.filter(reset => reset > now);
|
|
258
|
+
const next = Math.min(...resets);
|
|
259
|
+
if (next < earliest) {
|
|
260
|
+
earliest = next;
|
|
261
|
+
candidates = [id];
|
|
262
|
+
} else if (next === earliest) candidates.push(id);
|
|
263
|
+
}
|
|
264
|
+
return pickLowestUsageAmong(config, candidates, selectionOptions, now);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Fill-first: keep selectable active under threshold; otherwise advance to the next
|
|
269
|
+
* eligible id in stable sorted order after the current active (wrapping).
|
|
270
|
+
*/
|
|
271
|
+
function pickFillFirstCodexAccount(
|
|
272
|
+
config: OcxConfig,
|
|
273
|
+
now: number,
|
|
274
|
+
quotaScope?: CodexQuotaScope,
|
|
275
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
276
|
+
): string | null {
|
|
277
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
|
|
278
|
+
if (eligible.length === 0) return null;
|
|
279
|
+
|
|
280
|
+
const active = getEffectiveActiveCodexAccountId(config);
|
|
281
|
+
if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) {
|
|
282
|
+
return active;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Next eligible account in stable order after `afterId` (wrapping). */
|
|
289
|
+
function pickNextFillFirstCodexAccount(
|
|
290
|
+
config: OcxConfig,
|
|
291
|
+
afterId: string | null,
|
|
292
|
+
eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
|
|
293
|
+
now = Date.now(),
|
|
294
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
295
|
+
): string | null {
|
|
296
|
+
if (eligible.length === 0) return null;
|
|
297
|
+
const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
|
|
298
|
+
if (!afterId) {
|
|
299
|
+
// Prefer an under-threshold account when starting with no active cursor.
|
|
300
|
+
for (const id of ordered) {
|
|
301
|
+
if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
|
|
302
|
+
}
|
|
303
|
+
return ordered[0] ?? null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const allConfigured = [
|
|
307
|
+
...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
|
|
308
|
+
? [MAIN_CODEX_ACCOUNT_ID]
|
|
309
|
+
: []),
|
|
310
|
+
...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
|
|
311
|
+
];
|
|
312
|
+
const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b));
|
|
313
|
+
const startIdx = stableAll.indexOf(afterId);
|
|
314
|
+
if (startIdx < 0) {
|
|
315
|
+
for (const id of ordered) {
|
|
316
|
+
if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
|
|
317
|
+
}
|
|
318
|
+
return ordered[0] ?? null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Skip successors that are also at/above threshold (known drained usage).
|
|
322
|
+
let fallback: string | null = null;
|
|
323
|
+
for (let step = 1; step <= stableAll.length; step++) {
|
|
324
|
+
const candidate = stableAll[(startIdx + step) % stableAll.length]!;
|
|
325
|
+
if (!eligible.includes(candidate)) continue;
|
|
326
|
+
if (!fallback) fallback = candidate;
|
|
327
|
+
if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate;
|
|
328
|
+
}
|
|
329
|
+
return fallback ?? ordered[0] ?? null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Unbound new-session pick for round-robin / fill-first. Returns null to fall through
|
|
334
|
+
* to the legacy quota path (or when the strategy is quota).
|
|
335
|
+
*
|
|
336
|
+
* When `commit` is true (resolve path), advances RR state. `commitSharedActive`
|
|
337
|
+
* and `commitAffinity` independently control the two cross-request side effects:
|
|
338
|
+
* model-scoped entitlement selection can bind a new task without replacing an
|
|
339
|
+
* existing task binding or global active choice. Preview remains a dry-run peek.
|
|
340
|
+
*
|
|
341
|
+
* Automatic strategy picks never sync-write config; only manual selection persists active.
|
|
342
|
+
*
|
|
343
|
+
* Known limitation (follow-up): when a subagent preview peeks an RR account and the request
|
|
344
|
+
* then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding
|
|
345
|
+
* the peeked account if that path becomes load-bearing.
|
|
346
|
+
*/
|
|
347
|
+
export function pickUnboundStrategyAccount(
|
|
348
|
+
config: OcxConfig,
|
|
349
|
+
threadId: string | null,
|
|
350
|
+
now: number,
|
|
351
|
+
commit: boolean,
|
|
352
|
+
quotaScope?: CodexQuotaScope,
|
|
353
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
354
|
+
commitSharedActive = commit,
|
|
355
|
+
commitAffinity = commit,
|
|
356
|
+
): string | null {
|
|
357
|
+
const strategy = accountPoolStrategyForScope(config, quotaScope);
|
|
358
|
+
if (strategy === "quota") return null;
|
|
359
|
+
const poolKey = codexPoolKeyForScope(quotaScope);
|
|
360
|
+
|
|
361
|
+
let picked: string | null = null;
|
|
362
|
+
if (strategy === "round-robin") {
|
|
363
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
|
|
364
|
+
const limit = stickyLimitForConfig(config);
|
|
365
|
+
if (!commit) {
|
|
366
|
+
return peekRoundRobinAccount(poolKey, eligible, limit);
|
|
367
|
+
}
|
|
368
|
+
picked = pickRoundRobinAccount(poolKey, eligible, limit);
|
|
369
|
+
if (!picked) return null;
|
|
370
|
+
if (commitSharedActive) {
|
|
371
|
+
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
372
|
+
&& !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
|
|
373
|
+
rememberActiveCodexAccount(config, picked);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
377
|
+
notePoolRotationSuccess(poolKey, picked, limit);
|
|
378
|
+
return picked;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (strategy === "fill-first" || strategy === "reset-first") {
|
|
382
|
+
picked = strategy === "reset-first"
|
|
383
|
+
? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
|
|
384
|
+
: pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
|
|
385
|
+
if (!picked) return null;
|
|
386
|
+
if (commitSharedActive) {
|
|
387
|
+
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
388
|
+
&& !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
|
|
389
|
+
rememberActiveCodexAccount(config, picked);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
393
|
+
return picked;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
|
|
400
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
|
|
401
|
+
return (config.codexAccounts ?? [])
|
|
402
|
+
.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Selection-only main routing must not lazily read the fenced native credential for its plan. */
|
|
406
|
+
export function getPoolAccountPlanForSelection(
|
|
407
|
+
config: OcxConfig,
|
|
408
|
+
accountId: string,
|
|
409
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
410
|
+
): string | undefined {
|
|
411
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
return getPoolAccountPlan(config, accountId);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Shared routing state must ignore a request-scoped entitlement roster. */
|
|
418
|
+
export function sharedStateSelectionOptions(
|
|
419
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
420
|
+
): Pick<
|
|
421
|
+
CodexAccountUsabilityOptions,
|
|
422
|
+
"nativeMainSelectionOnly" | "isMainAccountTokenLive"
|
|
423
|
+
> | undefined {
|
|
424
|
+
if (!selectionOptions) return undefined;
|
|
425
|
+
return {
|
|
426
|
+
...(selectionOptions.nativeMainSelectionOnly !== undefined
|
|
427
|
+
? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly }
|
|
428
|
+
: {}),
|
|
429
|
+
...(selectionOptions.isMainAccountTokenLive
|
|
430
|
+
? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
|
|
431
|
+
: {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function pickLowerUsageAccount(
|
|
436
|
+
config: OcxConfig,
|
|
437
|
+
active: string,
|
|
438
|
+
activeUsage: number,
|
|
439
|
+
now: number,
|
|
440
|
+
quotaScope?: CodexQuotaScope,
|
|
441
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
442
|
+
skipFailoverReadyCandidates = false,
|
|
443
|
+
): string {
|
|
444
|
+
let best = active;
|
|
445
|
+
let bestUsage = activeUsage;
|
|
446
|
+
for (const id of getEligiblePoolAccounts(
|
|
447
|
+
config,
|
|
448
|
+
active,
|
|
449
|
+
now,
|
|
450
|
+
quotaScope,
|
|
451
|
+
selectionOptions,
|
|
452
|
+
skipFailoverReadyCandidates,
|
|
453
|
+
)) {
|
|
454
|
+
const usage = computeCodexUsageScore(
|
|
455
|
+
getAccountQuota(id),
|
|
456
|
+
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
457
|
+
now,
|
|
458
|
+
);
|
|
459
|
+
if (usage < bestUsage) {
|
|
460
|
+
best = id;
|
|
461
|
+
bestUsage = usage;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return best;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Coolest account in an already-selected candidate list; first index wins ties. */
|
|
468
|
+
export function pickLowestUsageAmong(
|
|
469
|
+
config: OcxConfig,
|
|
470
|
+
ids: readonly string[],
|
|
471
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
472
|
+
now: number = Date.now(),
|
|
473
|
+
): string | null {
|
|
474
|
+
let best: string | null = null;
|
|
475
|
+
let bestUsage = Number.POSITIVE_INFINITY;
|
|
476
|
+
for (const id of ids) {
|
|
477
|
+
const usage = computeCodexUsageScore(
|
|
478
|
+
getAccountQuota(id),
|
|
479
|
+
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
480
|
+
now,
|
|
481
|
+
);
|
|
482
|
+
if (usage < bestUsage) {
|
|
483
|
+
best = id;
|
|
484
|
+
bestUsage = usage;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return best;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function pickLowestUsageCodexAccount(
|
|
491
|
+
config: OcxConfig,
|
|
492
|
+
excludeId?: string,
|
|
493
|
+
now = Date.now(),
|
|
494
|
+
quotaScope?: CodexQuotaScope,
|
|
495
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
496
|
+
): string | null {
|
|
497
|
+
return pickLowestUsageAmong(
|
|
498
|
+
config,
|
|
499
|
+
getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions),
|
|
500
|
+
selectionOptions,
|
|
501
|
+
now,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
|
|
507
|
+
* and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
|
|
508
|
+
* round-robin takes the next ring pick (caller should have noted the failure).
|
|
509
|
+
*/
|
|
510
|
+
export function pickAlternateCodexAccount(
|
|
511
|
+
config: OcxConfig,
|
|
512
|
+
excludeId: string,
|
|
513
|
+
now = Date.now(),
|
|
514
|
+
quotaScope?: CodexQuotaScope,
|
|
515
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
516
|
+
): string | null {
|
|
517
|
+
const strategy = accountPoolStrategyForScope(config, quotaScope);
|
|
518
|
+
// The exclusion is passed into eligibility rather than post-filtered off its
|
|
519
|
+
// result: when the excluded account is the only healthy member of the top
|
|
520
|
+
// tier, the tier walk must be free to descend instead of selecting that tier
|
|
521
|
+
// and then handing back an empty list.
|
|
522
|
+
if (strategy === "round-robin") {
|
|
523
|
+
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
|
|
524
|
+
return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
|
|
525
|
+
}
|
|
526
|
+
if (strategy === "fill-first") {
|
|
527
|
+
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
|
|
528
|
+
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
|
|
529
|
+
}
|
|
530
|
+
if (strategy === "reset-first") {
|
|
531
|
+
return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions);
|
|
532
|
+
}
|
|
533
|
+
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The account {@link pickAlternateCodexAccount} WOULD return, without returning it.
|
|
538
|
+
*
|
|
539
|
+
* Only the round-robin branch has a side effect -- `pickRoundRobinAccount` commits the pick and
|
|
540
|
+
* advances the ring -- so every other strategy delegates rather than growing a second copy of
|
|
541
|
+
* the selection rule that could drift from it.
|
|
542
|
+
*
|
|
543
|
+
* This exists because preview and resolve have to agree on the FIRST transient detour, not just
|
|
544
|
+
* on later ones. Preview feeds subagent model-availability scoring, so a preview that reported
|
|
545
|
+
* the bound account while resolve was about to serve from a cool sibling could retire a model
|
|
546
|
+
* over usage the request would never have touched.
|
|
547
|
+
*/
|
|
548
|
+
export function peekAlternateCodexAccount(
|
|
549
|
+
config: OcxConfig,
|
|
550
|
+
excludeId: string,
|
|
551
|
+
now: number,
|
|
552
|
+
quotaScope?: CodexQuotaScope,
|
|
553
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
554
|
+
): string | null {
|
|
555
|
+
if (accountPoolStrategyForScope(config, quotaScope) === "round-robin") {
|
|
556
|
+
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
|
|
557
|
+
return peekRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
|
|
558
|
+
}
|
|
559
|
+
return pickAlternateCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function isUnknownUsage(usage: number): boolean {
|
|
563
|
+
return usage >= CODEX_UNKNOWN_USAGE_SCORE;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Move an unbound request back up when a higher tier regains headroom — the
|
|
568
|
+
* weekly-reset case. Returns null when nothing should change.
|
|
569
|
+
*
|
|
570
|
+
* Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
|
|
571
|
+
* fires when the tier filter has already excluded `active`, and only toward a
|
|
572
|
+
* tier that strictly outranks it. Threads bound by affinity never reach here.
|
|
573
|
+
*/
|
|
574
|
+
export function pickPriorityPreemption(
|
|
575
|
+
config: OcxConfig,
|
|
576
|
+
active: string,
|
|
577
|
+
now: number,
|
|
578
|
+
quotaScope?: CodexQuotaScope,
|
|
579
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
580
|
+
): string | null {
|
|
581
|
+
const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
|
|
582
|
+
if (eligible.length === 0 || eligible.includes(active)) return null;
|
|
583
|
+
const pinned = pinnedCodexAccountId(config);
|
|
584
|
+
// A live pin already lowered the tier ceiling; never preempt past an explicit
|
|
585
|
+
// operator choice. Same liveness test the tier filter applies, so preview and
|
|
586
|
+
// resolve agree even before the pin is garbage-collected.
|
|
587
|
+
if (
|
|
588
|
+
pinned !== undefined
|
|
589
|
+
&& eligible.includes(pinned)
|
|
590
|
+
&& hasCodexQuotaHeadroom(config, pinned, selectionOptions, now)
|
|
591
|
+
) return null;
|
|
592
|
+
const priorityOf = codexAccountPriorityLookup(config);
|
|
593
|
+
if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
|
|
594
|
+
// Members without headroom are in the tier only because a sibling has some;
|
|
595
|
+
// picking one would hand the request straight back to a drained account.
|
|
596
|
+
return pickLowestUsageAmong(
|
|
597
|
+
config,
|
|
598
|
+
eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)),
|
|
599
|
+
selectionOptions,
|
|
600
|
+
now,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function applyQuotaAutoSwitch(
|
|
605
|
+
config: OcxConfig,
|
|
606
|
+
active: string,
|
|
607
|
+
now: number,
|
|
608
|
+
quotaScope?: CodexQuotaScope,
|
|
609
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
610
|
+
commitSharedSelection = true,
|
|
611
|
+
): string {
|
|
612
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
613
|
+
if (threshold <= 0) return active;
|
|
614
|
+
const quota = getAccountQuota(active);
|
|
615
|
+
const activeUsage = computeCodexUsageScore(
|
|
616
|
+
quota,
|
|
617
|
+
getPoolAccountPlanForSelection(config, active, selectionOptions),
|
|
618
|
+
now,
|
|
619
|
+
);
|
|
620
|
+
// Unknown usage is not evidence that a user's explicit selection crossed the
|
|
621
|
+
// threshold. Wait for quota priming instead of rotating among guesses.
|
|
622
|
+
if (isUnknownUsage(activeUsage)) return active;
|
|
623
|
+
if (activeUsage < threshold) return active;
|
|
624
|
+
const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
|
|
625
|
+
if (best !== active) {
|
|
626
|
+
if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
|
|
627
|
+
setActiveCodexAccount(config, best);
|
|
628
|
+
}
|
|
629
|
+
return best;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return active;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
|
|
636
|
+
const threshold = config.upstreamFailoverThreshold ?? 3;
|
|
637
|
+
if (threshold <= 0) return false;
|
|
638
|
+
dropSpentCredentialFailure(accountId);
|
|
639
|
+
const health = getAccountHealth(accountId);
|
|
640
|
+
if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
|
|
641
|
+
return !!health && health.consecutiveFailures >= threshold;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function isHealthySharedCodexSelection(
|
|
645
|
+
config: OcxConfig,
|
|
646
|
+
accountId: string,
|
|
647
|
+
now: number,
|
|
648
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
649
|
+
selectionOptions: CodexAccountUsabilityOptions | undefined,
|
|
650
|
+
): boolean {
|
|
651
|
+
return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
|
|
652
|
+
&& hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)
|
|
653
|
+
&& !shouldFailover(config, accountId, now);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function strategySelectionOptionsForModelDetour(
|
|
657
|
+
config: OcxConfig,
|
|
658
|
+
now: number,
|
|
659
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
660
|
+
selectionOptions: CodexAccountUsabilityOptions | undefined,
|
|
661
|
+
): CodexAccountUsabilityOptions | undefined {
|
|
662
|
+
if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions;
|
|
663
|
+
const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {};
|
|
664
|
+
return {
|
|
665
|
+
...selectionOptions,
|
|
666
|
+
modelEligibleAccountIds: new Set(
|
|
667
|
+
[...selectionOptions.modelEligibleAccountIds].filter(accountId =>
|
|
668
|
+
isHealthySharedCodexSelection(
|
|
669
|
+
config,
|
|
670
|
+
accountId,
|
|
671
|
+
now,
|
|
672
|
+
quotaScope,
|
|
673
|
+
sharedSelectionOptions,
|
|
674
|
+
)
|
|
675
|
+
),
|
|
676
|
+
),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function applyFailureFailover(
|
|
681
|
+
config: OcxConfig,
|
|
682
|
+
active: string,
|
|
683
|
+
now: number,
|
|
684
|
+
quotaScope?: CodexQuotaScope,
|
|
685
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
686
|
+
commitSharedSelection = true,
|
|
687
|
+
): string {
|
|
688
|
+
if (!shouldFailover(config, active, now)) return active;
|
|
689
|
+
const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
690
|
+
if (best) {
|
|
691
|
+
// The scope still routes away from the failing account — that is this request's
|
|
692
|
+
// own decision — but an independent one must not persist a new shared active
|
|
693
|
+
// account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
|
|
694
|
+
// the moment of the failure; the streak outlives the soft avoid, so a later
|
|
695
|
+
// scoped resolve reaches here with the streak still tripped and would otherwise
|
|
696
|
+
// move the shared cursor after all.
|
|
697
|
+
if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
|
|
698
|
+
promoteActiveCodexAccount(config, best);
|
|
699
|
+
}
|
|
700
|
+
return best;
|
|
701
|
+
}
|
|
702
|
+
return active;
|
|
703
|
+
}
|