@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912
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-D_t6sCWs.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
package/src/codex/routing.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
seedPoolRotationAccount,
|
|
19
19
|
selectPriorityTier,
|
|
20
20
|
} from "./pool-rotation";
|
|
21
|
-
import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
21
|
+
import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "./quota";
|
|
22
22
|
import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan";
|
|
23
23
|
import {
|
|
24
24
|
MAIN_CODEX_ACCOUNT_ID,
|
|
@@ -63,6 +63,25 @@ type CodexUpstreamHealth = {
|
|
|
63
63
|
lastFailureAt?: number;
|
|
64
64
|
/** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
|
|
65
65
|
cooldownUntil?: number;
|
|
66
|
+
/**
|
|
67
|
+
* How long a quota refusal keeps selection away from this account (or this native quota
|
|
68
|
+
* group), as opposed to how long it is hard-blocked.
|
|
69
|
+
*
|
|
70
|
+
* The two are deliberately different lengths. {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS}
|
|
71
|
+
* caps the hard cooldown at 15 minutes because a reset announcement is advisory and plan
|
|
72
|
+
* quota usually frees up before it — an account must stay reachable so the pool can find
|
|
73
|
+
* that out (#433). The window the refusal announced is not 15 minutes, though, so once the
|
|
74
|
+
* cooldown lapses the account is selectable again while its burst window is still spent,
|
|
75
|
+
* and the strategy picks it straight back: this proxy reads a weekly bar a burst limit never
|
|
76
|
+
* touches, so a refused account still scores as the coolest in the pool. Every request then
|
|
77
|
+
* earns the same 429 until the process restarts, which is the only thing that drops this map.
|
|
78
|
+
*
|
|
79
|
+
* So the announcement governs avoidance and the cap still governs blocking. Avoidance is soft
|
|
80
|
+
* in the {@link softAvoidUntil} sense: it reorders the pool and releases a bound thread, and
|
|
81
|
+
* the last-resort paths still reach the account when nothing else can serve, so one pessimistic
|
|
82
|
+
* announcement cannot stall routing.
|
|
83
|
+
*/
|
|
84
|
+
quotaAvoidUntil?: number;
|
|
66
85
|
/** When the current cooldown was recorded; origin of the probe interval clock. */
|
|
67
86
|
cooldownSince?: number;
|
|
68
87
|
/**
|
|
@@ -112,6 +131,12 @@ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
|
112
131
|
* the Retry-After ceiling (#433).
|
|
113
132
|
*/
|
|
114
133
|
const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
|
|
134
|
+
/**
|
|
135
|
+
* Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window,
|
|
136
|
+
* tight enough that a weekly or monthly reset four days out cannot take an account out of
|
|
137
|
+
* rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows.
|
|
138
|
+
*/
|
|
139
|
+
const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
|
|
115
140
|
/** Minimum gap between probe leases for one cooled-down account. */
|
|
116
141
|
export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
|
|
117
142
|
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
@@ -315,14 +340,30 @@ export function clearThreadAccountMapForAccount(accountId: string): void {
|
|
|
315
340
|
}
|
|
316
341
|
|
|
317
342
|
export function clearCodexUpstreamHealth(): void {
|
|
343
|
+
// Operator preferences are routing state, not health, but they live and die with the same
|
|
344
|
+
// reset points. Leaving them behind lets a selection from one context suppress the
|
|
345
|
+
// automatic cursor in the next one.
|
|
346
|
+
manualPreference.clear();
|
|
318
347
|
upstreamHealth.clear();
|
|
319
348
|
quotaScopedHealth.clear();
|
|
320
349
|
runtimeActiveCodexAccountId = undefined;
|
|
350
|
+
// The reconcile watermark is part of this state, not something that outlives it. Keeping
|
|
351
|
+
// it across a full reset is incoherent: there is no health left to protect, yet
|
|
352
|
+
// recordCodexUpstreamOutcome would still drop a writer whose generation predates the
|
|
353
|
+
// watermark for any account missing from the equally stale live set. Left behind, it also
|
|
354
|
+
// leaks between test files, which is how it was found.
|
|
355
|
+
lastReconciledGeneration = 0;
|
|
356
|
+
liveHealthAccountIds = new Set();
|
|
321
357
|
}
|
|
322
358
|
|
|
323
359
|
export function clearCodexUpstreamHealthForAccount(accountId: string): void {
|
|
324
360
|
upstreamHealth.delete(accountId);
|
|
325
361
|
quotaScopedHealth.delete(accountId);
|
|
362
|
+
// Deletion is the third operator exit, next to pause and exclusion, and it is the one
|
|
363
|
+
// with no reconcile path behind it: once the account is gone nothing can succeed on it,
|
|
364
|
+
// so an unspent preference naming it would suppress the automatic cursor for every other
|
|
365
|
+
// account until the process restarts.
|
|
366
|
+
forgetManualPreference(accountId);
|
|
326
367
|
}
|
|
327
368
|
|
|
328
369
|
export function reconcileCodexRoutingHealth(context: GenerationContext): number {
|
|
@@ -338,6 +379,14 @@ export function reconcileCodexRoutingHealth(context: GenerationContext): number
|
|
|
338
379
|
quotaScopedHealth.delete(accountId);
|
|
339
380
|
removed += 1;
|
|
340
381
|
}
|
|
382
|
+
// Sweep preferences the same way, for the account set this generation actually has. The
|
|
383
|
+
// delete path above is the direct route; this is the one that catches an account removed
|
|
384
|
+
// by an edit the runtime never saw. Deliberately not counted in `removed`, which reports
|
|
385
|
+
// health rows.
|
|
386
|
+
for (const [poolKey, preferred] of manualPreference) {
|
|
387
|
+
if (context.codexAccountIds.has(preferred)) continue;
|
|
388
|
+
manualPreference.delete(poolKey);
|
|
389
|
+
}
|
|
341
390
|
liveHealthAccountIds = new Set(context.codexAccountIds);
|
|
342
391
|
lastReconciledGeneration = context.generation;
|
|
343
392
|
return removed;
|
|
@@ -405,9 +454,11 @@ export function computeCodexUsageScore(quota: {
|
|
|
405
454
|
* A short-only reading that proves the account is blocked NOW.
|
|
406
455
|
*
|
|
407
456
|
* Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
|
|
408
|
-
* carry
|
|
457
|
+
* carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
|
|
409
458
|
* hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
|
|
410
|
-
* five-hour window has since reset.
|
|
459
|
+
* five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
|
|
460
|
+
* explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
|
|
461
|
+
* That is #3029 pointed the other way: the issue is that
|
|
411
462
|
* an exhausted account stays selected, and "a recovered account stays excluded" trades one
|
|
412
463
|
* unusable pool for another.
|
|
413
464
|
*
|
|
@@ -433,12 +484,9 @@ function isTerminalShortWindow(
|
|
|
433
484
|
const age = now - observedAt;
|
|
434
485
|
return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
|
|
435
486
|
}
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
// reading looks like it reset in 1970 — a fix that passes its own test and does nothing.
|
|
440
|
-
const resetAtMs = resetAt < 10_000_000_000 ? resetAt * 1000 : resetAt;
|
|
441
|
-
return resetAtMs > now;
|
|
487
|
+
// Seconds and milliseconds both reach storage, so the split lives in one place next to the
|
|
488
|
+
// merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
|
|
489
|
+
return resetAtToMs(resetAt) > now;
|
|
442
490
|
}
|
|
443
491
|
|
|
444
492
|
export function classifyCodexUpstreamOutcome(
|
|
@@ -523,6 +571,51 @@ export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
|
|
|
523
571
|
return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
|
|
524
572
|
}
|
|
525
573
|
|
|
574
|
+
/**
|
|
575
|
+
* When the pool should stop preferring an account after it refused on quota.
|
|
576
|
+
*
|
|
577
|
+
* The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS},
|
|
578
|
+
* and never shorter than the cooldown the same refusal produced — a Retry-After directive that
|
|
579
|
+
* outlasts every announcement still governs.
|
|
580
|
+
*/
|
|
581
|
+
function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number {
|
|
582
|
+
const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt];
|
|
583
|
+
let announced: number | undefined;
|
|
584
|
+
for (const value of values) {
|
|
585
|
+
const timestamp = resetTimestampMs(value);
|
|
586
|
+
if (timestamp === undefined) continue;
|
|
587
|
+
const delay = timestamp - now;
|
|
588
|
+
if (delay <= 0) continue;
|
|
589
|
+
const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS);
|
|
590
|
+
if (announced === undefined || until < announced) announced = until;
|
|
591
|
+
}
|
|
592
|
+
return Math.max(cooldownUntil, announced ?? 0);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Live quota-refusal avoidance for an account, including the lane the request belongs to. */
|
|
596
|
+
function codexQuotaAvoidUntil(
|
|
597
|
+
accountId: string,
|
|
598
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
599
|
+
now: number,
|
|
600
|
+
): number | null {
|
|
601
|
+
const live = (value: number | undefined): number | null =>
|
|
602
|
+
typeof value === "number" && Number.isFinite(value) && value > now ? value : null;
|
|
603
|
+
const account = live(upstreamHealth.get(accountId)?.quotaAvoidUntil);
|
|
604
|
+
const scoped = quotaScope === undefined
|
|
605
|
+
? null
|
|
606
|
+
: live(scopedHealthFor(accountId, quotaScope)?.quotaAvoidUntil);
|
|
607
|
+
if (account === null) return scoped;
|
|
608
|
+
return scoped === null ? account : Math.max(account, scoped);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function isCodexQuotaAvoided(
|
|
612
|
+
accountId: string,
|
|
613
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
614
|
+
now: number,
|
|
615
|
+
): boolean {
|
|
616
|
+
return codexQuotaAvoidUntil(accountId, quotaScope, now) !== null;
|
|
617
|
+
}
|
|
618
|
+
|
|
526
619
|
export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
|
|
527
620
|
return computeQuotaCooldown(meta).until;
|
|
528
621
|
}
|
|
@@ -764,6 +857,10 @@ function settleCooldownRecoveryLease(claim: CooldownRecoveryLease, recovered: bo
|
|
|
764
857
|
cooldownSource: _source,
|
|
765
858
|
probeLeaseId: _leaseId,
|
|
766
859
|
probeLeaseGeneration: _leaseGeneration,
|
|
860
|
+
// "The quota window moved" is a statement about the whole refusal, so the avoidance it
|
|
861
|
+
// announced goes with the block it produced. Leaving it would make this escape hatch stop
|
|
862
|
+
// escaping: the account would still be passed over by every selection it is meant to win.
|
|
863
|
+
quotaAvoidUntil: _avoid,
|
|
767
864
|
...rest
|
|
768
865
|
} = health;
|
|
769
866
|
upstreamHealth.set(claim.accountId, {
|
|
@@ -871,6 +968,14 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
|
|
|
871
968
|
clearThreadAccountMap();
|
|
872
969
|
// Manual selection is the operator source of truth — drop any automatic runtime cursor.
|
|
873
970
|
runtimeActiveCodexAccountId = undefined;
|
|
971
|
+
// Record the pick as an unspent one-shot on the SHARED scope only. An independent scope
|
|
972
|
+
// gets no entry on purpose: every write site the guard protects is already skipped for
|
|
973
|
+
// independent scopes, so an entry there would be state nothing reads — and state nothing
|
|
974
|
+
// reads is what the next reader mistakes for a rule.
|
|
975
|
+
//
|
|
976
|
+
// Seeding happens ONLY here. A pool-driven promote must never create or move a preference,
|
|
977
|
+
// or the pool would manufacture an operator intent nobody expressed.
|
|
978
|
+
manualPreference.set(POOL_KEY_CODEX, accountId);
|
|
874
979
|
// Seed the RR ring so the next unbound new session honors the manually selected account
|
|
875
980
|
// under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
|
|
876
981
|
// config.activeCodexAccountId, which the caller persists before invoking this.
|
|
@@ -880,11 +985,27 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
|
|
|
880
985
|
seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
|
|
881
986
|
}
|
|
882
987
|
}
|
|
988
|
+
// Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming
|
|
989
|
+
// this account has overruled it. The hard cooldown is the part that survives.
|
|
990
|
+
const overrule = (health: CodexUpstreamHealth) => {
|
|
991
|
+
const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health);
|
|
992
|
+
return retained;
|
|
993
|
+
};
|
|
883
994
|
const current = upstreamHealth.get(accountId);
|
|
884
|
-
if (
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
995
|
+
if (current) {
|
|
996
|
+
const retained = overrule(current);
|
|
997
|
+
if (Object.keys(retained).length === 0) upstreamHealth.delete(accountId);
|
|
998
|
+
else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...retained });
|
|
999
|
+
}
|
|
1000
|
+
// A reset-derived refusal records its avoidance on the SCOPED map and returns before the
|
|
1001
|
+
// account-wide entry is written, so naming the account has to reach that map too. Stopping
|
|
1002
|
+
// at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in
|
|
1003
|
+
// the case that produces the avoidance this function exists to overrule.
|
|
1004
|
+
for (const [scope, health] of [...(quotaScopedHealth.get(accountId) ?? [])]) {
|
|
1005
|
+
const retained = overrule(health);
|
|
1006
|
+
if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope);
|
|
1007
|
+
else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained });
|
|
1008
|
+
}
|
|
888
1009
|
}
|
|
889
1010
|
|
|
890
1011
|
export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
|
|
@@ -953,18 +1074,31 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b
|
|
|
953
1074
|
* bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
|
|
954
1075
|
* today and is kept so the invariant survives a future change that retains the lease.
|
|
955
1076
|
*
|
|
956
|
-
* Returns false when the account carried
|
|
1077
|
+
* Returns false when the account carried neither a live cooldown nor a live avoidance window.
|
|
1078
|
+
* The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the
|
|
1079
|
+
* window runs up to six hours — so the moment an operator actually reaches for this escape
|
|
1080
|
+
* hatch is usually after the cooldown lapsed and only the window is still keeping the account
|
|
1081
|
+
* out of rotation. Refusing to look at the window then would leave the hatch shut in the one
|
|
1082
|
+
* case it exists for.
|
|
957
1083
|
*/
|
|
958
1084
|
export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
|
|
959
1085
|
const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
|
|
960
1086
|
const cooldownUntil = health.cooldownUntil;
|
|
961
|
-
|
|
1087
|
+
const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now;
|
|
1088
|
+
const avoidUntil = health.quotaAvoidUntil;
|
|
1089
|
+
const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now;
|
|
1090
|
+
if (!liveCooldown && !liveAvoidance) return null;
|
|
962
1091
|
const {
|
|
963
1092
|
cooldownUntil: _until,
|
|
964
1093
|
cooldownSince: _since,
|
|
965
1094
|
cooldownSource: _source,
|
|
966
1095
|
probeLeaseId: _leaseId,
|
|
967
1096
|
probeLeaseGeneration: _leaseGeneration,
|
|
1097
|
+
// Same reasoning as the probe recovery above: "the quota window moved" is a statement
|
|
1098
|
+
// about the whole refusal, so the avoidance it announced goes with the block it
|
|
1099
|
+
// produced. Keeping it would leave this escape hatch not escaping, because selection
|
|
1100
|
+
// would still pass over the account for as long as the announced window runs.
|
|
1101
|
+
quotaAvoidUntil: _avoid,
|
|
968
1102
|
...rest
|
|
969
1103
|
} = health;
|
|
970
1104
|
return {
|
|
@@ -1058,6 +1192,7 @@ function isCodexAccountSelectable(
|
|
|
1058
1192
|
return !isCodexAccountPaused(config, accountId)
|
|
1059
1193
|
&& !isCodexAccountPlanExcluded(config, accountId)
|
|
1060
1194
|
&& getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
|
|
1195
|
+
&& !isCodexQuotaAvoided(accountId, quotaScope, now)
|
|
1061
1196
|
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
1062
1197
|
&& isCodexAccountUsable(config, accountId, selectionOptions);
|
|
1063
1198
|
}
|
|
@@ -1297,6 +1432,7 @@ function getEligiblePoolAccounts(
|
|
|
1297
1432
|
&& (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
|
|
1298
1433
|
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
|
1299
1434
|
.filter(account => !isCodexAccountSoftAvoided(account.id, now))
|
|
1435
|
+
.filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now))
|
|
1300
1436
|
.filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
|
|
1301
1437
|
.map(account => account.id);
|
|
1302
1438
|
// The main Codex account is not stored in config.codexAccounts; include it as a
|
|
@@ -1307,6 +1443,12 @@ function getEligiblePoolAccounts(
|
|
|
1307
1443
|
&& (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
|
|
1308
1444
|
&& getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
|
|
1309
1445
|
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
1446
|
+
// The main login is not in `config.codexAccounts`, so it never passes through the
|
|
1447
|
+
// filters above and this is the only place an avoidance window can exclude it. Without
|
|
1448
|
+
// this the window a refusal announced applies to the pool but not to the account that
|
|
1449
|
+
// earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and
|
|
1450
|
+
// in between the main account returns as a first-class candidate.
|
|
1451
|
+
&& !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
|
|
1310
1452
|
&& (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
|
|
1311
1453
|
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
|
|
1312
1454
|
) {
|
|
@@ -1467,7 +1609,10 @@ function pickUnboundStrategyAccount(
|
|
|
1467
1609
|
picked = pickRoundRobinAccount(poolKey, eligible, limit);
|
|
1468
1610
|
if (!picked) return null;
|
|
1469
1611
|
if (commitSharedActive) {
|
|
1470
|
-
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
1612
|
+
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
1613
|
+
&& !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
|
|
1614
|
+
rememberActiveCodexAccount(config, picked);
|
|
1615
|
+
}
|
|
1471
1616
|
}
|
|
1472
1617
|
if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
1473
1618
|
notePoolRotationSuccess(poolKey, picked, limit);
|
|
@@ -1478,7 +1623,10 @@ function pickUnboundStrategyAccount(
|
|
|
1478
1623
|
picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
|
|
1479
1624
|
if (!picked) return null;
|
|
1480
1625
|
if (commitSharedActive) {
|
|
1481
|
-
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
1626
|
+
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
1627
|
+
&& !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
|
|
1628
|
+
rememberActiveCodexAccount(config, picked);
|
|
1629
|
+
}
|
|
1482
1630
|
}
|
|
1483
1631
|
if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
1484
1632
|
return picked;
|
|
@@ -1622,6 +1770,53 @@ export function pickAlternateCodexAccount(
|
|
|
1622
1770
|
}
|
|
1623
1771
|
|
|
1624
1772
|
/** Effective active: automatic runtime cursor, else operator/persisted selection. */
|
|
1773
|
+
/**
|
|
1774
|
+
* Unspent operator selections, keyed by pool scope.
|
|
1775
|
+
*
|
|
1776
|
+
* Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness
|
|
1777
|
+
* cannot be detected by comparing values: a pool-driven promote legitimately moves the
|
|
1778
|
+
* persisted active account, and reading that as staleness would silently spend the
|
|
1779
|
+
* operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual
|
|
1780
|
+
* selection, the account leaving the pool, or a successful dispatch on it.
|
|
1781
|
+
*/
|
|
1782
|
+
const manualPreference = new Map<string, string>();
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Spend the one-shot for a pool scope once a dispatch on that account actually succeeded.
|
|
1786
|
+
* This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks.
|
|
1787
|
+
*
|
|
1788
|
+
* Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in
|
|
1789
|
+
* place and no consume site, the first manual selection freezes the automatic cursor
|
|
1790
|
+
* permanently and 15 of 69 rotation tests fail.
|
|
1791
|
+
*/
|
|
1792
|
+
function consumeManualPreference(accountId: string, poolKey: string): void {
|
|
1793
|
+
if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Drop an account's preference in every scope. Pause and exclusion do not route through
|
|
1798
|
+
* `resetCodexRoutingForManualSelection`, so without this a preference could outlive the
|
|
1799
|
+
* account it names and keep suppressing the automatic cursor.
|
|
1800
|
+
*/
|
|
1801
|
+
function forgetManualPreference(accountId: string): void {
|
|
1802
|
+
for (const [poolKey, preferred] of manualPreference) {
|
|
1803
|
+
if (preferred === accountId) manualPreference.delete(poolKey);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* True while an unspent operator selection for this scope names a DIFFERENT account than
|
|
1809
|
+
* the automatic pick about to be recorded.
|
|
1810
|
+
*
|
|
1811
|
+
* Callers pass their own scope: an independent quota scope keeps its own entry and must
|
|
1812
|
+
* never read the shared one. The failover promote does NOT consult this — see its call
|
|
1813
|
+
* site for why.
|
|
1814
|
+
*/
|
|
1815
|
+
function manualPreferenceBlocks(poolKey: string, accountId: string): boolean {
|
|
1816
|
+
const preferred = manualPreference.get(poolKey);
|
|
1817
|
+
return preferred !== undefined && preferred !== accountId;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1625
1820
|
export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
|
|
1626
1821
|
return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
|
|
1627
1822
|
}
|
|
@@ -1690,6 +1885,10 @@ export function reconcileCodexActiveAfterExclusion(
|
|
|
1690
1885
|
now = Date.now(),
|
|
1691
1886
|
): string | null {
|
|
1692
1887
|
const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId;
|
|
1888
|
+
// Exclusion does not route through resetCodexRoutingForManualSelection, so the one-shot is
|
|
1889
|
+
// revoked here too. A preference naming an account that can no longer serve would keep
|
|
1890
|
+
// suppressing the automatic cursor with no way to clear it.
|
|
1891
|
+
forgetManualPreference(excludedAccountId);
|
|
1693
1892
|
if (config.activeCodexAccountId === excludedAccountId) {
|
|
1694
1893
|
config.activeCodexAccountId = undefined;
|
|
1695
1894
|
}
|
|
@@ -1892,6 +2091,45 @@ export function resolveCodexAccountForThread(
|
|
|
1892
2091
|
return resolution.status === "selected" ? resolution.accountId : null;
|
|
1893
2092
|
}
|
|
1894
2093
|
|
|
2094
|
+
function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean {
|
|
2095
|
+
return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/**
|
|
2099
|
+
* Has this account refused a request on quota without serving one since?
|
|
2100
|
+
*
|
|
2101
|
+
* Thread affinity is a prompt-cache optimization and every rule around it is a preference:
|
|
2102
|
+
* `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity`
|
|
2103
|
+
* deliberately raises that bar further. A refusal is not a preference, and once the account has
|
|
2104
|
+
* told THIS thread it cannot serve, the binding has nothing left to optimize.
|
|
2105
|
+
*
|
|
2106
|
+
* The distinction matters because the cooldown a 429 writes is deliberately short. A reset
|
|
2107
|
+
* announcement is advisory — plan quota routinely frees up before the advertised instant — so
|
|
2108
|
+
* {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that
|
|
2109
|
+
* announcement describes is not capped, so an account whose burst window is spent looks
|
|
2110
|
+
* selectable again long before it is. For an unbound request that is correct: going back to find
|
|
2111
|
+
* out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit —
|
|
2112
|
+
* the cooldown lapses, the account still scores lowest on the only window this proxy has a
|
|
2113
|
+
* reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the
|
|
2114
|
+
* identical 429. Cleared affinity does not help: the next request re-derives the same choice.
|
|
2115
|
+
* From the Codex side that reads exactly as reported — a new session rotates normally while an
|
|
2116
|
+
* existing one is locked to an exhausted account until the proxy is restarted, because a restart
|
|
2117
|
+
* is the only thing that drops the binding and the stale health together.
|
|
2118
|
+
*
|
|
2119
|
+
* `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields}
|
|
2120
|
+
* strips it from every recovery write, so it survives exactly until the account actually serves a
|
|
2121
|
+
* request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes
|
|
2122
|
+
* the account and the first success releases every thread this refused.
|
|
2123
|
+
*
|
|
2124
|
+
* Scope follows where the refusal was recorded. An account-wide throttle lands in
|
|
2125
|
+
* `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native
|
|
2126
|
+
* quota group, so a spent Spark window still cannot displace the same thread's Terra binding.
|
|
2127
|
+
*/
|
|
2128
|
+
function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean {
|
|
2129
|
+
if (carriesQuotaRefusal(upstreamHealth.get(accountId))) return true;
|
|
2130
|
+
return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope));
|
|
2131
|
+
}
|
|
2132
|
+
|
|
1895
2133
|
function previewReusableAffinityAccount(
|
|
1896
2134
|
entry: ThreadAffinityEntry | undefined,
|
|
1897
2135
|
config: OcxConfig,
|
|
@@ -1904,6 +2142,7 @@ function previewReusableAffinityAccount(
|
|
|
1904
2142
|
|| isThreadAffinityExpired(entry, now)
|
|
1905
2143
|
|| !isThreadAffinityGenerationLive(entry)
|
|
1906
2144
|
|| !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
|
|
2145
|
+
|| hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope)
|
|
1907
2146
|
|| shouldFailover(config, entry.accountId, now)
|
|
1908
2147
|
) {
|
|
1909
2148
|
return null;
|
|
@@ -1918,7 +2157,9 @@ function previewReusableAffinityAccount(
|
|
|
1918
2157
|
getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
|
|
1919
2158
|
now,
|
|
1920
2159
|
);
|
|
1921
|
-
|
|
2160
|
+
// Preview must agree with resolve: this is the second copy of the same rule, and the
|
|
2161
|
+
// suite asserts the two answer identically.
|
|
2162
|
+
if (mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) {
|
|
1922
2163
|
const best = pickLowerUsageAccount(
|
|
1923
2164
|
config,
|
|
1924
2165
|
entry.accountId,
|
|
@@ -1935,6 +2176,32 @@ function previewReusableAffinityAccount(
|
|
|
1935
2176
|
return entry.accountId;
|
|
1936
2177
|
}
|
|
1937
2178
|
|
|
2179
|
+
/**
|
|
2180
|
+
* May a LIVE binding be moved for quota reasons?
|
|
2181
|
+
*
|
|
2182
|
+
* Default: yes once usage crosses `autoSwitchThreshold`, which is the historical rule.
|
|
2183
|
+
*
|
|
2184
|
+
* With `pool.cacheAffinity` on, the bar becomes genuine exhaustion. Moving a bound
|
|
2185
|
+
* conversation discards the prompt cache warmed on its account, so a threshold crossing -- a
|
|
2186
|
+
* hint that the account is getting busy -- does not justify paying that cost; the account has
|
|
2187
|
+
* to be unable to serve. Deliberately NOT `hasCodexQuotaHeadroom`, which reads
|
|
2188
|
+
* `usage < autoSwitchThreshold` and would reproduce the old rule under a new name.
|
|
2189
|
+
*/
|
|
2190
|
+
function mayRebindAffinityForQuota(
|
|
2191
|
+
config: OcxConfig,
|
|
2192
|
+
accountId: string,
|
|
2193
|
+
usage: number,
|
|
2194
|
+
threshold: number,
|
|
2195
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
2196
|
+
): boolean {
|
|
2197
|
+
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
2198
|
+
if (config.pool?.cacheAffinity !== true) return overThreshold;
|
|
2199
|
+
// The usable half is already guaranteed by both callers, which gate on
|
|
2200
|
+
// isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
|
|
2201
|
+
return !isCodexAccountUsable(config, accountId, selectionOptions)
|
|
2202
|
+
|| (!isUnknownUsage(usage) && usage >= 100);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
1938
2205
|
/**
|
|
1939
2206
|
* Re-evaluate an affined account under the quota strategy. Returns a strictly
|
|
1940
2207
|
* cooler replacement, or null when the current binding should remain.
|
|
@@ -1955,15 +2222,18 @@ function reevaluateAffinityQuota(
|
|
|
1955
2222
|
now,
|
|
1956
2223
|
)
|
|
1957
2224
|
: 0;
|
|
1958
|
-
|
|
2225
|
+
// One bar, used for BOTH the rebind decision and the re-score interval. Keying the short
|
|
2226
|
+
// circuit off the old threshold while the rebind bar moved would re-score a bound thread on
|
|
2227
|
+
// every request through the whole 80-99% band instead of once a minute.
|
|
2228
|
+
const mayRebind = mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions);
|
|
1959
2229
|
if (
|
|
1960
|
-
!
|
|
2230
|
+
!mayRebind
|
|
1961
2231
|
&& now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS
|
|
1962
2232
|
) {
|
|
1963
2233
|
return null;
|
|
1964
2234
|
}
|
|
1965
2235
|
entry.lastReevalAt = now;
|
|
1966
|
-
if (!
|
|
2236
|
+
if (!mayRebind) return null;
|
|
1967
2237
|
const best = pickLowerUsageAccount(
|
|
1968
2238
|
config,
|
|
1969
2239
|
entry.accountId,
|
|
@@ -2106,6 +2376,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
2106
2376
|
const detourReusable = !isThreadAffinityExpired(detourEntry, now)
|
|
2107
2377
|
&& isThreadAffinityGenerationLive(detourEntry)
|
|
2108
2378
|
&& isCodexAccountSelectable(config, detourEntry.accountId, now, quotaScope, selectionOptions)
|
|
2379
|
+
&& !hasUnrecoveredCodexQuotaRefusal(detourEntry.accountId, quotaScope)
|
|
2109
2380
|
&& !shouldFailover(config, detourEntry.accountId, now);
|
|
2110
2381
|
if (detourReusable) {
|
|
2111
2382
|
detourEntry.lastUsedAt = now;
|
|
@@ -2143,11 +2414,16 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
2143
2414
|
const selectableForRequest = selectableForSharedState
|
|
2144
2415
|
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions);
|
|
2145
2416
|
const failoverReady = shouldFailover(config, entry.accountId, now);
|
|
2417
|
+
// A quota refusal outranks every affinity preference, including `pool.cacheAffinity`:
|
|
2418
|
+
// the account has already told this thread it cannot serve it.
|
|
2419
|
+
const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope);
|
|
2146
2420
|
const healthyForSharedAffinity = selectableForSharedState
|
|
2147
2421
|
&& hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now)
|
|
2422
|
+
&& !quotaRefused
|
|
2148
2423
|
&& !failoverReady;
|
|
2149
2424
|
if (
|
|
2150
2425
|
selectableForRequest
|
|
2426
|
+
&& !quotaRefused
|
|
2151
2427
|
// Affined threads must leave a failing account once the streak trips failover
|
|
2152
2428
|
// (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
|
|
2153
2429
|
&& !failoverReady
|
|
@@ -2210,6 +2486,13 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
2210
2486
|
&& !preserveSharedSelectionForModelDetour
|
|
2211
2487
|
&& !isIndependentCodexQuotaScope(quotaScope)
|
|
2212
2488
|
) {
|
|
2489
|
+
// NOT guarded by manualPreferenceBlocks, unlike preemption below. Measured: guarding
|
|
2490
|
+
// it fails 8 cases in tests/codex-integration/codex-routing.test.ts, because a model
|
|
2491
|
+
// detour is not the pool exercising discretion — the operator's account cannot serve
|
|
2492
|
+
// this model at all. Under a rotating strategy this promote only moves the
|
|
2493
|
+
// process-local cursor to whoever is actually serving and releases the pin; the
|
|
2494
|
+
// operator's persisted activeCodexAccountId is left untouched either way, which is
|
|
2495
|
+
// the thing the preference exists to protect.
|
|
2213
2496
|
promoteActiveCodexAccount(config, strategyPick);
|
|
2214
2497
|
}
|
|
2215
2498
|
return { status: "selected", accountId: strategyPick };
|
|
@@ -2283,7 +2566,10 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
2283
2566
|
!preserveSharedSelectionForModelDetour
|
|
2284
2567
|
&& !isIndependentCodexQuotaScope(quotaScope)
|
|
2285
2568
|
) {
|
|
2286
|
-
|
|
2569
|
+
// Preemption is an automatic pick competing with the operator, so it yields.
|
|
2570
|
+
if (!manualPreferenceBlocks(POOL_KEY_CODEX, preempted)) {
|
|
2571
|
+
rememberActiveCodexAccount(config, preempted);
|
|
2572
|
+
}
|
|
2287
2573
|
}
|
|
2288
2574
|
active = preempted;
|
|
2289
2575
|
}
|
|
@@ -2354,6 +2640,9 @@ export function recordCodexUpstreamOutcome(
|
|
|
2354
2640
|
*/
|
|
2355
2641
|
dropSpentCredentialFailure(accountId);
|
|
2356
2642
|
if (outcomeClass === "success") {
|
|
2643
|
+
// The operator's one-shot is spent by a dispatch that actually worked, and only by that.
|
|
2644
|
+
// A failed lookup leaves it unspent so the intent survives the failure.
|
|
2645
|
+
consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
|
|
2357
2646
|
const scopedProbe = meta.probeQuotaScope
|
|
2358
2647
|
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
2359
2648
|
: undefined;
|
|
@@ -2364,6 +2653,20 @@ export function recordCodexUpstreamOutcome(
|
|
|
2364
2653
|
setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
|
|
2365
2654
|
}
|
|
2366
2655
|
}
|
|
2656
|
+
// A served request is what ends the refusal marker the quota branch left on this lane.
|
|
2657
|
+
// The probe contract above owns the scoped COOLDOWN; this owns only the field
|
|
2658
|
+
// {@link hasUnrecoveredCodexQuotaRefusal} reads, which would otherwise keep threads away
|
|
2659
|
+
// from an account that is demonstrably serving them again. The account-wide marker needs
|
|
2660
|
+
// no equivalent: every recovery write below runs it through preservedCooldownFields.
|
|
2661
|
+
const refusedScope = quotaScope ? scopedHealthFor(accountId, quotaScope) : undefined;
|
|
2662
|
+
if (quotaScope && refusedScope && carriesQuotaRefusal(refusedScope)) {
|
|
2663
|
+
const {
|
|
2664
|
+
lastFailureStatus: _refusal, lastFailureAt: _refusedAt, quotaAvoidUntil: _avoid, ...retained
|
|
2665
|
+
} = refusedScope;
|
|
2666
|
+
// A live cooldown and its probe bookkeeping survive; an entry that held nothing else goes.
|
|
2667
|
+
if (Object.keys(retained).length > 1) setScopedHealth(accountId, quotaScope, retained);
|
|
2668
|
+
else deleteScopedHealth(accountId, quotaScope);
|
|
2669
|
+
}
|
|
2367
2670
|
const current = upstreamHealth.get(accountId);
|
|
2368
2671
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
2369
2672
|
// A leased probe that is still on its own cooldown generation proves the
|
|
@@ -2507,6 +2810,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
2507
2810
|
lastFailureStatus,
|
|
2508
2811
|
lastFailureAt: now,
|
|
2509
2812
|
cooldownUntil: until,
|
|
2813
|
+
quotaAvoidUntil: quotaAvoidUntilFor(meta, now, until),
|
|
2510
2814
|
cooldownSince: now,
|
|
2511
2815
|
cooldownSource: source,
|
|
2512
2816
|
cooldownGeneration,
|
|
@@ -2555,6 +2859,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
2555
2859
|
lastFailureStatus,
|
|
2556
2860
|
lastFailureAt: now,
|
|
2557
2861
|
cooldownUntil: until,
|
|
2862
|
+
quotaAvoidUntil: quotaAvoidUntilFor(meta, now, until),
|
|
2558
2863
|
cooldownSince: now,
|
|
2559
2864
|
cooldownSource: source,
|
|
2560
2865
|
cooldownGeneration,
|
package/src/codex/warmup.ts
CHANGED
|
@@ -27,8 +27,8 @@ export interface CodexWarmupOptions {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
30
|
-
const DEFAULT_MODEL = "gpt-5.
|
|
31
|
-
const FALLBACK_MODELS = ["gpt-5.5"
|
|
30
|
+
const DEFAULT_MODEL = "gpt-5.6-luna";
|
|
31
|
+
const FALLBACK_MODELS = ["gpt-5.5"];
|
|
32
32
|
const isRetryableWarmupStatus = (status?: number): boolean => status === 400 || status === 404;
|
|
33
33
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
34
34
|
const MAX_TIMEOUT_MS = 0x7fff_ffff;
|
package/src/combos/failover.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseResetCooldownMs } from "../codex/routing";
|
|
2
2
|
import { classifyError, isCyberPolicyCode } from "../lib/errors";
|
|
3
|
+
import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
|
|
3
4
|
import type { OcxComboTarget } from "../types";
|
|
4
5
|
import { targetKey } from "./types";
|
|
5
6
|
import {
|
|
@@ -413,6 +414,10 @@ export function comboFailureDecision(
|
|
|
413
414
|
): ComboFailureDecision {
|
|
414
415
|
if (status === 499) return "stop";
|
|
415
416
|
if (message.toLowerCase().includes("origin_rejected")) return "stop";
|
|
417
|
+
// The origin may already be executing this turn (the Codex WebSocket relay sent the create
|
|
418
|
+
// frame and never saw a response event). Hopping would send the same request to a second
|
|
419
|
+
// target while the first may still be generating; the honest status goes to the client.
|
|
420
|
+
if (isNonReplayableUpstreamCode(options?.code)) return "stop";
|
|
416
421
|
// Cyber policy is a hard non-retryable refusal — honor structured code even when
|
|
417
422
|
// classificationText was truncated before the JSON code field.
|
|
418
423
|
if (isCyberPolicyCode(options?.code)) return "stop";
|