@bitkyc08/opencodex 2.56.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +32 -1
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +7 -1
- package/src/bridge/sse.ts +19 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +157 -7
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/restore.ts +29 -2
- package/src/codex/inject.ts +9 -9
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/pending-teardown.ts +31 -0
- package/src/generated/compatibility-version.json +163 -135
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/registry/entries-core.ts +7 -0
- package/src/providers/registry/entries-extended.ts +9 -0
- package/src/providers/registry/model-seeds.ts +4 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +6 -2
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +103 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +19 -6
- package/src/server/responses/passthrough-dispatch.ts +28 -10
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +132 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +4 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "../quota";
|
|
6
6
|
import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
7
7
|
import type { CodexQuotaScope } from "./health-store";
|
|
8
|
+
import type { TransientProbeGrant } from "./thread-affinity";
|
|
8
9
|
|
|
9
10
|
export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
10
11
|
export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
@@ -78,6 +79,15 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
78
79
|
probeLeaseId?: string;
|
|
79
80
|
/** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
|
|
80
81
|
probeQuotaScope?: CodexQuotaScope;
|
|
82
|
+
/**
|
|
83
|
+
* The half-open TRANSIENT-HOLD probe this request was granted, when it was the one request
|
|
84
|
+
* admitted to test a held account (#4701). A different lease to `probeLeaseId` above, in a
|
|
85
|
+
* different domain: that one governs a quota cooldown, this one governs a 5xx hold. The two
|
|
86
|
+
* are mutually exclusive by construction -- `isTransientOnlyAffinityBlock` refuses to
|
|
87
|
+
* recognise a transient hold on an account that carries quota health -- so a request never
|
|
88
|
+
* holds both and never pays two recovery permits for one send.
|
|
89
|
+
*/
|
|
90
|
+
transientProbe?: TransientProbeGrant;
|
|
81
91
|
/**
|
|
82
92
|
* Already-chosen alternate for same-request 429 retry. When set, promotion
|
|
83
93
|
* reuses this account instead of calling {@link pickAlternateCodexAccount}
|
|
@@ -123,6 +123,39 @@ export function codexAccountBlockReason(
|
|
|
123
123
|
return undefined;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Drop accounts a confirmed roster says cannot serve this model, unless that leaves nothing.
|
|
128
|
+
*
|
|
129
|
+
* The restore-on-empty is the whole safety argument, not a defensive afterthought. Roster
|
|
130
|
+
* evidence can be wrong in the direction that matters: a shard that has not caught up reports a
|
|
131
|
+
* denial for a model the account genuinely owns, and #3022 is what happens when absence is
|
|
132
|
+
* allowed to remove a model outright. Because this can only ever return a non-empty subset of a
|
|
133
|
+
* list the caller already computed, no pool that would have found a working account can be left
|
|
134
|
+
* without one — the worst case is the selection that ships today.
|
|
135
|
+
*
|
|
136
|
+
* It is an ordering rule rather than an eligibility one for the same reason. Nothing below
|
|
137
|
+
* reports `model_not_entitled`, nothing refuses before dispatch, and the existing bounded
|
|
138
|
+
* alternate-account retry on an exact unsupported-model 400 stays exactly where it is as the
|
|
139
|
+
* safety net. This only stops the pool from CHOOSING an account that has already told us it
|
|
140
|
+
* cannot serve the model (#4768).
|
|
141
|
+
*
|
|
142
|
+
* An operator's manual pin is never dropped. Roster evidence orders the pool's own discretion;
|
|
143
|
+
* it does not overrule an explicit human choice, and removing the pinned account here would do
|
|
144
|
+
* more than demote it -- `selectPriorityTier` reads the pin to lower the tier ceiling, so a pin
|
|
145
|
+
* filtered out beforehand stops acting as a ceiling at all and silently re-enables tiers the
|
|
146
|
+
* operator had excluded. An operator who pins an account upstream will refuse still gets the
|
|
147
|
+
* alternate-account retry; what they do not get is the pool quietly deciding they were wrong.
|
|
148
|
+
*/
|
|
149
|
+
export function withoutModelDeniedAccounts(
|
|
150
|
+
ids: readonly string[],
|
|
151
|
+
denied: ReadonlySet<string> | undefined,
|
|
152
|
+
pinned?: string,
|
|
153
|
+
): readonly string[] {
|
|
154
|
+
if (denied === undefined || ids.length === 0) return ids;
|
|
155
|
+
const remaining = ids.filter(id => !denied.has(id) || id === pinned);
|
|
156
|
+
return remaining.length > 0 ? remaining : ids;
|
|
157
|
+
}
|
|
158
|
+
|
|
126
159
|
export function getEligiblePoolAccounts(
|
|
127
160
|
config: OcxConfig,
|
|
128
161
|
excludeId?: string,
|
|
@@ -168,11 +201,16 @@ export function getEligiblePoolAccounts(
|
|
|
168
201
|
// Single choke point for selection order: every strategy, failover, and preview
|
|
169
202
|
// reaches the pool through here, so tiering applies once rather than per picker.
|
|
170
203
|
// Eligibility above is unchanged — this only narrows an already-eligible list.
|
|
204
|
+
//
|
|
205
|
+
// Model entitlement is applied BEFORE the priority tier, because a tier is a quota-ordering
|
|
206
|
+
// question and an account that cannot serve the model at all should not be the reason a tier
|
|
207
|
+
// is selected. Both steps narrow an already-eligible list and neither can empty it.
|
|
208
|
+
const pinned = pinnedCodexAccountId(config);
|
|
171
209
|
return selectPriorityTier(
|
|
172
|
-
ids,
|
|
210
|
+
withoutModelDeniedAccounts(ids, selectionOptions?.deniedModelAccountIds, pinned),
|
|
173
211
|
codexAccountPriorityLookup(config),
|
|
174
212
|
id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
|
|
175
|
-
|
|
213
|
+
pinned,
|
|
176
214
|
);
|
|
177
215
|
}
|
|
178
216
|
|
|
@@ -563,6 +601,45 @@ export function isUnknownUsage(usage: number): boolean {
|
|
|
563
601
|
return usage >= CODEX_UNKNOWN_USAGE_SCORE;
|
|
564
602
|
}
|
|
565
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Correct a shared cursor that names an account this model's own roster denies (#4768).
|
|
606
|
+
*
|
|
607
|
+
* {@link getEligiblePoolAccounts} is not the only door into selection. An account that is already
|
|
608
|
+
* ACTIVE is served straight from {@link isCodexAccountSelectable} and never passes through the
|
|
609
|
+
* eligible list, so ordering that list alone left the exact case the issue reports: once the Free
|
|
610
|
+
* account becomes the cursor, every Sol/Astra request keeps going to it and keeps taking the
|
|
611
|
+
* upstream unsupported-model 400. {@link pickPriorityPreemption} does not cover it either -- it
|
|
612
|
+
* refuses to move toward a tier that does not strictly outrank the active one, which is the usual
|
|
613
|
+
* shape here.
|
|
614
|
+
*
|
|
615
|
+
* Three properties keep this inside "order the already-eligible set" rather than widening it.
|
|
616
|
+
* It admits nothing: the replacement comes from {@link getEligiblePoolAccounts}, so every
|
|
617
|
+
* eligibility guard has already passed on it. It cannot fail: with no entitled alternative the
|
|
618
|
+
* active account is returned unchanged, so this can never turn a served request into `none`.
|
|
619
|
+
* And it changes nothing without evidence: absent `deniedModelAccountIds`, or an active account
|
|
620
|
+
* nobody denied, it is the identity function.
|
|
621
|
+
*
|
|
622
|
+
* The caller must NOT persist the result. This is one request's correction for one model, in the
|
|
623
|
+
* same spirit as a model detour; the operator's cursor is theirs. A pinned active account is
|
|
624
|
+
* exempt outright, for the reason {@link withoutModelDeniedAccounts} gives.
|
|
625
|
+
*/
|
|
626
|
+
export function preferModelEntitledAccount(
|
|
627
|
+
config: OcxConfig,
|
|
628
|
+
active: string,
|
|
629
|
+
now: number,
|
|
630
|
+
quotaScope?: CodexQuotaScope,
|
|
631
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
632
|
+
): string {
|
|
633
|
+
const denied = selectionOptions?.deniedModelAccountIds;
|
|
634
|
+
if (denied === undefined || !denied.has(active)) return active;
|
|
635
|
+
if (pinnedCodexAccountId(config) === active) return active;
|
|
636
|
+
// The eligible list restores denied members when filtering would empty it, so re-filter here:
|
|
637
|
+
// moving from one denied account to another buys nothing and costs the warm prefix.
|
|
638
|
+
const entitled = getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)
|
|
639
|
+
.filter(id => !denied.has(id));
|
|
640
|
+
return pickLowestUsageAmong(config, entitled, selectionOptions, now) ?? active;
|
|
641
|
+
}
|
|
642
|
+
|
|
566
643
|
/**
|
|
567
644
|
* Move an unbound request back up when a higher tier regains headroom — the
|
|
568
645
|
* weekly-reset case. Returns null when nothing should change.
|
|
@@ -4,6 +4,8 @@ import { retainedUtf8Bytes } from "../../lib/admission";
|
|
|
4
4
|
import { clearAllCodexPoolRefreshFailures } from "../pool-refresh-backoff";
|
|
5
5
|
import type { CodexThreadLineage } from "../lineage";
|
|
6
6
|
import type { CodexQuotaScope } from "./health-store";
|
|
7
|
+
import type { TransientProbeLease } from "../../routing/probe-lease";
|
|
8
|
+
import { clearPoolRecoveryState } from "../../routing/probe-lease";
|
|
7
9
|
|
|
8
10
|
export type ThreadAffinityEntry = {
|
|
9
11
|
accountId: string;
|
|
@@ -25,10 +27,51 @@ export type ThreadAffinityEntry = {
|
|
|
25
27
|
transientDetourAccountId?: string;
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The half-open trial this request was granted against its own held account (#4701).
|
|
32
|
+
*
|
|
33
|
+
* The lease alone is not enough to settle safely. Its generation is an account-local PROBE
|
|
34
|
+
* epoch, while {@link ThreadAffinityEntry.generation} is the selected CREDENTIAL generation,
|
|
35
|
+
* and the two move independently: a credential replaced while the probe is in flight leaves
|
|
36
|
+
* the probe epoch untouched, so a settle that checked only the lease would write an answer
|
|
37
|
+
* about a credential that no longer exists. Capturing the affinity generation here is what
|
|
38
|
+
* lets the settle refuse that case.
|
|
39
|
+
*/
|
|
40
|
+
export interface TransientProbeGrant {
|
|
41
|
+
readonly lease: TransientProbeLease;
|
|
42
|
+
/** Credential generation the binding held when the probe was granted. */
|
|
43
|
+
readonly affinityGeneration: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export type CodexThreadResolution =
|
|
29
|
-
| {
|
|
47
|
+
| {
|
|
48
|
+
status: "selected";
|
|
49
|
+
accountId: string;
|
|
50
|
+
affinity?: CodexAffinityDecision;
|
|
51
|
+
/**
|
|
52
|
+
* Present only when this request is the single admitted probe of a held account. The
|
|
53
|
+
* holder owes the lease a settle or a release; nothing else may act on it.
|
|
54
|
+
*/
|
|
55
|
+
transientProbe?: TransientProbeGrant;
|
|
56
|
+
}
|
|
30
57
|
| { status: "none"; affinity?: CodexAffinityDecision }
|
|
31
|
-
| { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }
|
|
58
|
+
| { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }
|
|
59
|
+
/**
|
|
60
|
+
* Every candidate for this binding is held and no detour is left, so there is no account
|
|
61
|
+
* this request may be sent to. Distinct from `none`: the binding is REMEMBERED and the
|
|
62
|
+
* caller is told when to come back, rather than being handed the account already known to
|
|
63
|
+
* be failing. Returning `selected` here is the "must not send, sends anyway" defect
|
|
64
|
+
* (#4701); the caller must refuse before any upstream I/O.
|
|
65
|
+
*/
|
|
66
|
+
| {
|
|
67
|
+
status: "withheld";
|
|
68
|
+
accountId: string;
|
|
69
|
+
/** Earliest moment a recovery dispatch could be admitted. Always strictly in the future. */
|
|
70
|
+
retryAt: number;
|
|
71
|
+
/** The remembered detour, when one exists but is itself unusable right now. */
|
|
72
|
+
detourAccountId?: string;
|
|
73
|
+
affinity?: CodexAffinityDecision;
|
|
74
|
+
};
|
|
32
75
|
|
|
33
76
|
/** What happened to this thread's binding on this request (#4546). */
|
|
34
77
|
export type CodexAffinityMove =
|
|
@@ -163,6 +206,11 @@ export function clearThreadAccountMap(): void {
|
|
|
163
206
|
// A refresh cooldown is per-account runtime state learned alongside these bindings. Leaving it
|
|
164
207
|
// behind here keeps an account out of selection after the roster it belonged to is gone.
|
|
165
208
|
clearAllCodexPoolRefreshFailures();
|
|
209
|
+
// Same argument for recovery state (#4701): probe pacing is keyed on account ids this reset
|
|
210
|
+
// may have just retired, and the recovery window counts sends made by the roster that is
|
|
211
|
+
// going away. A held account nobody may probe because of a lease issued against the previous
|
|
212
|
+
// roster is a recovery that never starts.
|
|
213
|
+
clearPoolRecoveryState();
|
|
166
214
|
conversationStateIssuerMap.clear();
|
|
167
215
|
}
|
|
168
216
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isCodexAccountGenerationLive } from "../account-store";
|
|
2
|
+
// From `../account-id`, which declares the constant and imports nothing, rather than from
|
|
3
|
+
// `../main-account`, which re-exports it and sits inside the routing/account-lifecycle import
|
|
4
|
+
// cycle. Neither reference here runs at module load, but a leaf import keeps this module out of
|
|
5
|
+
// that cycle entirely instead of relying on that staying true.
|
|
6
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../account-id";
|
|
7
|
+
import {
|
|
8
|
+
invalidateTransientProbe,
|
|
9
|
+
releaseTransientProbe,
|
|
10
|
+
resolveHeldAccountDispatch,
|
|
11
|
+
settleTransientProbe,
|
|
12
|
+
} from "../../routing/probe-lease";
|
|
13
|
+
import {
|
|
14
|
+
CODEX_TRANSIENT_AFFINITY_HOLD_MS,
|
|
15
|
+
type CodexThreadResolution,
|
|
16
|
+
type ThreadAffinityEntry,
|
|
17
|
+
type TransientProbeGrant,
|
|
18
|
+
} from "./thread-affinity";
|
|
19
|
+
import type { CodexUpstreamOutcomeClass, CodexUpstreamOutcomeMeta } from "./cooldown-math";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a request bound to a HELD account may actually do, and how its trial ends (#4701).
|
|
23
|
+
*
|
|
24
|
+
* The transient hold (#4546) keeps a thread's binding while its own account serves a 5xx
|
|
25
|
+
* streak and detours the request to a healthy sibling. Both of the selector's hold branches
|
|
26
|
+
* used to end the same way when no sibling could take it: they returned the held account as
|
|
27
|
+
* `selected`, and the caller sent at an account already known to be failing. Under a
|
|
28
|
+
* provider-wide 503 that is every bound request at once -- the amplification the hold exists
|
|
29
|
+
* to prevent rather than cause.
|
|
30
|
+
*
|
|
31
|
+
* This module is the seam between the selector and the bounded answer in
|
|
32
|
+
* `src/routing/probe-lease.ts`. It is separate from `./probe-lease` in this same directory,
|
|
33
|
+
* which is the unrelated QUOTA-COOLDOWN lease; the two govern different domains and must never
|
|
34
|
+
* settle each other's probe.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Has a held binding waited longer than a transient failure can reasonably explain? */
|
|
38
|
+
export function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean {
|
|
39
|
+
return entry.transientHoldSince !== undefined
|
|
40
|
+
&& now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where a request bound to a held account goes this turn.
|
|
45
|
+
*
|
|
46
|
+
* {@link resolveHeldAccountDispatch} bounds the answer: one probe tests the held account, and a
|
|
47
|
+
* caller with nowhere else to go is WITHHELD and told when to come back rather than sent at the
|
|
48
|
+
* failure.
|
|
49
|
+
*
|
|
50
|
+
* A usable detour is taken BEFORE that resolver is consulted, which inverts its own probe-first
|
|
51
|
+
* ordering. Deliberately: a healthy sibling is always a better answer for a live request than an
|
|
52
|
+
* account carrying a failure streak, and turning the first request after a hold into the trial
|
|
53
|
+
* would spend a real user's turn on it. The ordering is not what #4701 bounds -- the defect is
|
|
54
|
+
* the third answer the selector used to give, "send at the failing account anyway", and that is
|
|
55
|
+
* reached only when no detour exists. Recovery is still discovered there, because that is
|
|
56
|
+
* exactly the case where nothing else can find out.
|
|
57
|
+
*
|
|
58
|
+
* The caller has already committed `transientHoldSince`/`lastUsedAt`; this decides only where
|
|
59
|
+
* the request goes. A withheld answer deliberately leaves `transientDetourAccountId` alone:
|
|
60
|
+
* being unable to send right now says nothing about which sibling was serving this thread.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveTransientHoldDispatch(
|
|
63
|
+
entry: ThreadAffinityEntry,
|
|
64
|
+
detour: string | null,
|
|
65
|
+
now: number,
|
|
66
|
+
): CodexThreadResolution {
|
|
67
|
+
if (detour !== null && detour !== entry.accountId) {
|
|
68
|
+
entry.transientDetourAccountId = detour;
|
|
69
|
+
// Deliberately no promotion and no rebind: this is one request routing around a blip, not
|
|
70
|
+
// the pool deciding where the conversation now lives.
|
|
71
|
+
return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } };
|
|
72
|
+
}
|
|
73
|
+
const dispatch = resolveHeldAccountDispatch({ boundAccountId: entry.accountId, now });
|
|
74
|
+
if (dispatch.kind === "probe") {
|
|
75
|
+
return {
|
|
76
|
+
status: "selected",
|
|
77
|
+
accountId: entry.accountId,
|
|
78
|
+
affinity: { move: "held", reason: "transient" },
|
|
79
|
+
// The credential generation travels with the lease so a settle can refuse an answer about
|
|
80
|
+
// a credential this binding no longer has. See {@link TransientProbeGrant}.
|
|
81
|
+
transientProbe: { lease: dispatch.lease, affinityGeneration: entry.generation },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (dispatch.kind === "withheld") {
|
|
85
|
+
return {
|
|
86
|
+
status: "withheld",
|
|
87
|
+
accountId: dispatch.boundAccountId,
|
|
88
|
+
retryAt: dispatch.retryAt,
|
|
89
|
+
// The remembered sibling, when there is one. It is unusable right now -- that is why this
|
|
90
|
+
// request is refused -- but it is what has been serving this thread, and a refusal that
|
|
91
|
+
// dropped it would make the next resolve re-pick cold.
|
|
92
|
+
...(entry.transientDetourAccountId !== undefined
|
|
93
|
+
? { detourAccountId: entry.transientDetourAccountId }
|
|
94
|
+
: {}),
|
|
95
|
+
affinity: { move: "held", reason: "transient" },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Unreachable: no detour was handed in, so the resolver has none to hand back. Kept total
|
|
99
|
+
// rather than cast away, because the cost of being wrong here is a send at a failing account.
|
|
100
|
+
entry.transientDetourAccountId = dispatch.accountId;
|
|
101
|
+
return { status: "selected", accountId: dispatch.accountId, affinity: { move: "detour", reason: "transient" } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Does the credential a probe was granted against still exist at that generation? */
|
|
105
|
+
function transientProbeCredentialLive(accountId: string, generation: number): boolean {
|
|
106
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return generation === 0;
|
|
107
|
+
return isCodexAccountGenerationLive(accountId, generation);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Conclude the half-open recovery probe this request was holding.
|
|
112
|
+
*
|
|
113
|
+
* Three answers, because three things can be true of a probe that just ended:
|
|
114
|
+
*
|
|
115
|
+
* - The credential moved under it. Its result describes an identity the binding no longer has,
|
|
116
|
+
* so the epoch is BURNED instead of settled -- invalidating makes every outstanding lease on
|
|
117
|
+
* this account stale at once, which is what stops a late answer from reviving a dead account.
|
|
118
|
+
* - The answer says nothing about the account. A 3xx, a 400, or an unclassifiable status is the
|
|
119
|
+
* request's problem, not the account's, so the lease is handed back unspent and the next
|
|
120
|
+
* request may run a real trial instead of waiting out a recovery nobody observed.
|
|
121
|
+
* - Otherwise it is evidence: success means recovered, everything else means still failing.
|
|
122
|
+
*
|
|
123
|
+
* A no-op when this request held no trial, so the outcome recorder calls it unconditionally.
|
|
124
|
+
*/
|
|
125
|
+
export function settleTransientProbeForOutcome(
|
|
126
|
+
accountId: string,
|
|
127
|
+
meta: Pick<CodexUpstreamOutcomeMeta, "transientProbe" | "now">,
|
|
128
|
+
outcomeClass: CodexUpstreamOutcomeClass,
|
|
129
|
+
): void {
|
|
130
|
+
const grant: TransientProbeGrant | undefined = meta.transientProbe;
|
|
131
|
+
if (!grant) return;
|
|
132
|
+
if (!transientProbeCredentialLive(accountId, grant.affinityGeneration)) {
|
|
133
|
+
invalidateTransientProbe(accountId);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (outcomeClass === "neutral" || outcomeClass === "caller" || outcomeClass === "unknown") {
|
|
137
|
+
releaseTransientProbe(grant.lease);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
settleTransientProbe(grant.lease, outcomeClass === "success" ? "recovered" : "failed", meta.now ?? Date.now());
|
|
141
|
+
}
|
package/src/codex/routing.ts
CHANGED
|
@@ -54,6 +54,13 @@ import {
|
|
|
54
54
|
type CodexUpstreamHealth,
|
|
55
55
|
} from "./routing/health-store";
|
|
56
56
|
import { ownsProbeLease, probeMayClearCooldown, withProbeLeaseReleased } from "./routing/probe-lease";
|
|
57
|
+
// `./routing/probe-lease` above is the QUOTA-COOLDOWN lease; the module below owns the
|
|
58
|
+
// unrelated TRANSIENT-HOLD trial and the pool-wide recovery bound above it (#4701).
|
|
59
|
+
import {
|
|
60
|
+
isTransientHoldExpired,
|
|
61
|
+
resolveTransientHoldDispatch,
|
|
62
|
+
settleTransientProbeForOutcome,
|
|
63
|
+
} from "./routing/transient-hold-dispatch";
|
|
57
64
|
import {
|
|
58
65
|
adoptLegacyLineageAffinity,
|
|
59
66
|
affinityAfterRelease,
|
|
@@ -85,7 +92,6 @@ import {
|
|
|
85
92
|
getPoolAccountPlanForSelection,
|
|
86
93
|
hasCodexQuotaHeadroom,
|
|
87
94
|
isCodexAccountPlanExcluded,
|
|
88
|
-
isCacheAffinityEnabled,
|
|
89
95
|
isCodexAccountSelectable,
|
|
90
96
|
isHealthySharedCodexSelection,
|
|
91
97
|
isUnknownUsage,
|
|
@@ -96,11 +102,13 @@ import {
|
|
|
96
102
|
pickPriorityPreemption,
|
|
97
103
|
pickResetFirstCodexAccount,
|
|
98
104
|
pickUnboundStrategyAccount,
|
|
105
|
+
preferModelEntitledAccount,
|
|
99
106
|
sharedStateSelectionOptions,
|
|
100
107
|
strategySelectionOptionsForModelDetour,
|
|
101
108
|
shouldFailover,
|
|
102
109
|
peekAlternateCodexAccount,
|
|
103
110
|
} from "./routing/selection";
|
|
111
|
+
import { mayRebindAffinityForQuota } from "./routing/cache-affinity";
|
|
104
112
|
import {
|
|
105
113
|
clearAllManualPreferences,
|
|
106
114
|
consumeManualPreference,
|
|
@@ -181,6 +189,7 @@ export type {
|
|
|
181
189
|
CodexAffinityMove,
|
|
182
190
|
CodexAffinityReason,
|
|
183
191
|
CodexAffinityDecision,
|
|
192
|
+
TransientProbeGrant,
|
|
184
193
|
} from "./routing/thread-affinity";
|
|
185
194
|
export {
|
|
186
195
|
isCodexAccountPlanExcluded,
|
|
@@ -276,12 +285,6 @@ function isTransientOnlyAffinityBlock(
|
|
|
276
285
|
|| isCodexPoolRefreshCooling(entry.accountId, now);
|
|
277
286
|
}
|
|
278
287
|
|
|
279
|
-
/** Has a held binding waited longer than a transient failure can reasonably explain? */
|
|
280
|
-
function isTransientHoldExpired(entry: ThreadAffinityEntry, now: number): boolean {
|
|
281
|
-
return entry.transientHoldSince !== undefined
|
|
282
|
-
&& now - entry.transientHoldSince > CODEX_TRANSIENT_AFFINITY_HOLD_MS;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
288
|
/**
|
|
286
289
|
* Is every pin this thread holds on the failing account past its hold window?
|
|
287
290
|
*
|
|
@@ -477,6 +480,8 @@ export function resolveCodexAccountForThread(
|
|
|
477
480
|
lineage?: CodexThreadLineage,
|
|
478
481
|
): string | null {
|
|
479
482
|
const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope, undefined, undefined, lineage);
|
|
483
|
+
// A WITHHELD dispatch is deliberately not an account here: this wrapper cannot carry a retry
|
|
484
|
+
// time, and answering with the held account is the send the hold prevents. Fails closed.
|
|
480
485
|
return resolution.status === "selected" ? resolution.accountId : null;
|
|
481
486
|
}
|
|
482
487
|
|
|
@@ -583,34 +588,6 @@ function previewReusableAffinityAccount(
|
|
|
583
588
|
return entry.accountId;
|
|
584
589
|
}
|
|
585
590
|
|
|
586
|
-
/**
|
|
587
|
-
* May a LIVE binding be moved for quota reasons?
|
|
588
|
-
*
|
|
589
|
-
* Default: no. The bar is genuine exhaustion, because moving a bound conversation discards
|
|
590
|
-
* the prompt cache warmed on its account and a threshold crossing is a hint that the account
|
|
591
|
-
* is getting busy rather than evidence it cannot serve (#4546). Deliberately NOT
|
|
592
|
-
* `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the
|
|
593
|
-
* old rule under a new name.
|
|
594
|
-
*
|
|
595
|
-
* With `pool.cacheAffinity: false` the historical rule comes back: a crossing of
|
|
596
|
-
* `autoSwitchThreshold` is enough. That is capacity-first routing, and an operator who wants
|
|
597
|
-
* it keeps it -- but it is no longer what an install gets by never having heard of the flag.
|
|
598
|
-
*/
|
|
599
|
-
function mayRebindAffinityForQuota(
|
|
600
|
-
config: OcxConfig,
|
|
601
|
-
accountId: string,
|
|
602
|
-
usage: number,
|
|
603
|
-
threshold: number,
|
|
604
|
-
selectionOptions?: CodexAccountUsabilityOptions,
|
|
605
|
-
): boolean {
|
|
606
|
-
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
607
|
-
if (!isCacheAffinityEnabled(config)) return overThreshold;
|
|
608
|
-
// The usable half is already guaranteed by both callers, which gate on
|
|
609
|
-
// isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
|
|
610
|
-
return !isCodexAccountUsable(config, accountId, selectionOptions)
|
|
611
|
-
|| (!isUnknownUsage(usage) && usage >= 100);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
591
|
/** Reset ordering may move a binding only under the existing cache-affinity release policy. */
|
|
615
592
|
function resetFirstAffinityReplacement(
|
|
616
593
|
entry: ThreadAffinityEntry,
|
|
@@ -843,6 +820,9 @@ export function previewCodexAccountForRequest(
|
|
|
843
820
|
const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
844
821
|
if (best) active = best;
|
|
845
822
|
}
|
|
823
|
+
// Same correction resolve applies, for the same reason: preview must name the account the
|
|
824
|
+
// request will actually use, or subagent fallback scores a model against the wrong one.
|
|
825
|
+
active = preferModelEntitledAccount(config, active, now, quotaScope, selectionOptions);
|
|
846
826
|
if (!isCodexAccountUsable(config, active, selectionOptions)) {
|
|
847
827
|
return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
|
|
848
828
|
}
|
|
@@ -936,15 +916,12 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
936
916
|
const lane = transientDetourAccount(config, detourEntry, now, quotaScope, selectionOptions);
|
|
937
917
|
detourEntry.transientHoldSince ??= now;
|
|
938
918
|
detourEntry.lastUsedAt = now;
|
|
939
|
-
if (lane !== null && lane !== detourEntry.accountId) {
|
|
940
|
-
detourEntry.transientDetourAccountId = lane;
|
|
941
|
-
return { status: "selected", accountId: lane, affinity: { move: "detour", reason: "transient" } };
|
|
942
|
-
}
|
|
943
919
|
// A provider-wide outage soft-avoids every sibling, so there is nowhere to detour.
|
|
944
920
|
// That is a statement about where this request can go, not about who owns the
|
|
945
921
|
// conversation: dropping the pin here would rebuild the cold prefix elsewhere for
|
|
946
|
-
// exactly the failure
|
|
947
|
-
|
|
922
|
+
// exactly the failure the hold exists to survive -- nor a licence to send at the
|
|
923
|
+
// failing account, which is what the dispatch resolver bounds (#4701).
|
|
924
|
+
return resolveTransientHoldDispatch(detourEntry, lane, now);
|
|
948
925
|
}
|
|
949
926
|
// Detour expiry or invalidation must not expire the ordinary task. Drop only
|
|
950
927
|
// this model lane and select from ordinary/shared state below.
|
|
@@ -1018,16 +995,11 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1018
995
|
const detour = transientDetourAccount(config, entry, now, quotaScope, selectionOptions);
|
|
1019
996
|
entry.transientHoldSince ??= now;
|
|
1020
997
|
entry.lastUsedAt = now;
|
|
1021
|
-
if (detour !== null && detour !== entry.accountId) {
|
|
1022
|
-
entry.transientDetourAccountId = detour;
|
|
1023
|
-
// Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing
|
|
1024
|
-
// around a blip, not the pool deciding where the conversation now lives.
|
|
1025
|
-
return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } };
|
|
1026
|
-
}
|
|
1027
998
|
// No sibling can take it either -- the usual shape of a provider-wide 503. The binding
|
|
1028
999
|
// survives: "cannot send right now" and "forget which account owns this conversation"
|
|
1029
|
-
// are different answers
|
|
1030
|
-
|
|
1000
|
+
// are different answers. So is the third answer this used to give -- "send at the
|
|
1001
|
+
// failing account" -- now a bounded probe or a typed refusal (#4701).
|
|
1002
|
+
return resolveTransientHoldDispatch(entry, detour, now);
|
|
1031
1003
|
}
|
|
1032
1004
|
// A model-only exclusion does not invalidate the shared task binding. Health,
|
|
1033
1005
|
// generation, pause, cooldown, and failure evidence still retire it normally.
|
|
@@ -1241,6 +1213,10 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1241
1213
|
selectionOptions,
|
|
1242
1214
|
!preserveSharedSelectionForModelDetour,
|
|
1243
1215
|
);
|
|
1216
|
+
// The shared cursor can name an account whose own roster denies this model, and an active
|
|
1217
|
+
// account never passes through the eligible list. Correct it for THIS request only -- nothing
|
|
1218
|
+
// is persisted -- and only toward an account eligibility already admitted (#4768).
|
|
1219
|
+
active = preferModelEntitledAccount(config, active, now, quotaScope, selectionOptions);
|
|
1244
1220
|
if (!isCodexAccountUsable(config, active, selectionOptions)) {
|
|
1245
1221
|
return hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1246
1222
|
? { status: "selected", accountId: active, affinity: affinityAfterRelease(threadId, releaseReason) }
|
|
@@ -1277,6 +1253,10 @@ export function recordCodexUpstreamOutcome(
|
|
|
1277
1253
|
recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() });
|
|
1278
1254
|
}
|
|
1279
1255
|
if (!accountId) return;
|
|
1256
|
+
// Conclude the half-open recovery trial BEFORE the admissibility gate below (#4701): an
|
|
1257
|
+
// outcome that gate drops still ended this request, and a lease nobody hands back leaves the
|
|
1258
|
+
// next trial waiting out its deadline. The settle carries its own fences, so this is safe here.
|
|
1259
|
+
settleTransientProbeForOutcome(accountId, meta, classifyCodexUpstreamOutcome(outcome, meta.denial));
|
|
1280
1260
|
const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
|
|
1281
1261
|
if (!isHealthAccountAdmissible(accountId, writerGeneration)) return;
|
|
1282
1262
|
const now = meta.now ?? Date.now();
|
package/src/codex/warmup.ts
CHANGED
|
@@ -44,7 +44,7 @@ async function drainErrorBody(res: Response, signal: AbortSignal): Promise<void>
|
|
|
44
44
|
fatalUtf8: true,
|
|
45
45
|
});
|
|
46
46
|
} catch (error) {
|
|
47
|
-
if (signal.aborted) {
|
|
47
|
+
if (signal.aborted && res.status !== 429) {
|
|
48
48
|
throw new CodexWarmupError("transport", "Codex warmup request failed", {
|
|
49
49
|
cause: error,
|
|
50
50
|
});
|
package/src/combos/failover.ts
CHANGED
|
@@ -401,11 +401,15 @@ export function comboFailureCooldownScope(
|
|
|
401
401
|
): ComboFailureCooldownScope {
|
|
402
402
|
const code = normalizedFailureCode(options?.code);
|
|
403
403
|
// Request-shape refusals first: an oversized request must not cool a healthy target.
|
|
404
|
+
// A native transport can surface a zero-output model overflow as a generic
|
|
405
|
+
// upstream_server_error carrying precise context-window prose, so consult the bounded
|
|
406
|
+
// message classifier too: that target is healthy, the turn was simply too large for it.
|
|
404
407
|
if (
|
|
405
408
|
status === 413
|
|
406
409
|
|| REQUEST_SHAPE_FAILURE_CODES.has(code)
|
|
407
410
|
|| isRequestLocalFreePromptCap(status, message, options?.code)
|
|
408
411
|
|| isProviderTargetContextOverflow(status, message, options?.code)
|
|
412
|
+
|| isDefiniteContextOverflow(status, message)
|
|
409
413
|
|| isRequestLocalTargetIncompatibility(status, message, options?.code)
|
|
410
414
|
) return "none";
|
|
411
415
|
if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
|
|
@@ -451,6 +455,74 @@ function isProviderTargetContextOverflow(
|
|
|
451
455
|
&& /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message);
|
|
452
456
|
}
|
|
453
457
|
|
|
458
|
+
/** A status can carry a verdict about the REQUEST; 401/403/429 speak about the credential. */
|
|
459
|
+
const CONTEXT_VERDICT_STATUSES: ReadonlySet<number> = new Set([400, 413, 422]);
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Phrases a provider emits when the INPUT does not fit this model's context window. Matched
|
|
463
|
+
* against the innermost provider message only, so an unrelated refusal that merely quotes one
|
|
464
|
+
* of these tokens in a code field cannot authorize a replay.
|
|
465
|
+
*/
|
|
466
|
+
const DEFINITE_CONTEXT_OVERFLOW_PHRASES = [
|
|
467
|
+
"exceeds the context window",
|
|
468
|
+
"exceed the context window",
|
|
469
|
+
"context window exceeded",
|
|
470
|
+
"context length exceeded",
|
|
471
|
+
"maximum context length",
|
|
472
|
+
"maximum context window",
|
|
473
|
+
"too many tokens",
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
/** Wrapper envelopes unwrapped before the leaf message is read. */
|
|
477
|
+
const MAX_CONTEXT_OVERFLOW_ENVELOPES = 4;
|
|
478
|
+
|
|
479
|
+
function isDefiniteContextOverflowMessage(text: string): boolean {
|
|
480
|
+
const normalized = text.toLowerCase();
|
|
481
|
+
return normalized === "context_length_exceeded"
|
|
482
|
+
|| DEFINITE_CONTEXT_OVERFLOW_PHRASES.some(phrase => normalized.includes(phrase));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Confirm a context overflow from the provider MESSAGE rather than from a code token that
|
|
487
|
+
* merely appears somewhere in the envelope. An upstream controls both fields and can emit a
|
|
488
|
+
* contradictory pair -- `context_length_exceeded` beside `Unsupported parameter: user` -- and
|
|
489
|
+
* that is not evidence the turn is too large for this model. `classifyError` reads the whole
|
|
490
|
+
* blob, which is exactly the looseness this must not inherit.
|
|
491
|
+
*
|
|
492
|
+
* A JSON-shaped body that fails to parse is truncated or corrupt, not prose: `classificationText`
|
|
493
|
+
* is capped at 500 characters by `normalizeUpstreamErrorText` before it reaches this function, so
|
|
494
|
+
* a long envelope arrives here as a JSON prefix. Reading that prefix as plain text would let an
|
|
495
|
+
* arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed.
|
|
496
|
+
*
|
|
497
|
+
* Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters.
|
|
498
|
+
*/
|
|
499
|
+
function isDefiniteContextOverflow(status: number, message: string): boolean {
|
|
500
|
+
if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false;
|
|
501
|
+
if (message.length > 16_384) return false;
|
|
502
|
+
let text = message.trim();
|
|
503
|
+
// One pass per unwrapped envelope, plus one for the leaf the last envelope yields.
|
|
504
|
+
for (let unwrapped = 0; unwrapped <= MAX_CONTEXT_OVERFLOW_ENVELOPES; unwrapped += 1) {
|
|
505
|
+
const providerPrefix = /^Provider error \d{3}:\s*/.exec(text);
|
|
506
|
+
if (providerPrefix) text = text.slice(providerPrefix[0].length).trim();
|
|
507
|
+
if (!text.startsWith("{")) return isDefiniteContextOverflowMessage(text);
|
|
508
|
+
if (unwrapped === MAX_CONTEXT_OVERFLOW_ENVELOPES) return false;
|
|
509
|
+
let payload: unknown;
|
|
510
|
+
try { payload = JSON.parse(text); } catch { return false; }
|
|
511
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
512
|
+
const record = payload as Record<string, unknown>;
|
|
513
|
+
const response = record.response && typeof record.response === "object" && !Array.isArray(record.response)
|
|
514
|
+
? record.response as Record<string, unknown>
|
|
515
|
+
: undefined;
|
|
516
|
+
const source = [record.error, response?.error, response?.last_error, record.last_error, record]
|
|
517
|
+
.find((candidate): candidate is Record<string, unknown> =>
|
|
518
|
+
!!candidate && typeof candidate === "object" && !Array.isArray(candidate)
|
|
519
|
+
&& typeof (candidate as Record<string, unknown>).message === "string");
|
|
520
|
+
if (!source) return false;
|
|
521
|
+
text = (source.message as string).trim();
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
454
526
|
export function comboFailureDecision(
|
|
455
527
|
status: number,
|
|
456
528
|
message: string,
|
|
@@ -458,6 +530,10 @@ export function comboFailureDecision(
|
|
|
458
530
|
): ComboFailureDecision {
|
|
459
531
|
if (status === 499) return "stop";
|
|
460
532
|
if (message.toLowerCase().includes("origin_rejected")) return "stop";
|
|
533
|
+
// Structured form of the same hard refusal. The prose test above misses it when the origin
|
|
534
|
+
// reports the code out of band, and every hop rule below -- including the context-overflow
|
|
535
|
+
// one -- must stay subordinate to it.
|
|
536
|
+
if (normalizedFailureCode(options?.code) === "origin_rejected") return "stop";
|
|
461
537
|
// The origin may already be executing this turn (the Codex WebSocket relay sent the create
|
|
462
538
|
// frame and never saw a response event). Hopping would send the same request to a second
|
|
463
539
|
// target while the first may still be generating; the honest status goes to the client.
|
|
@@ -476,6 +552,15 @@ export function comboFailureDecision(
|
|
|
476
552
|
// (for example 5059 + invalid_request_prompt_too_long). That is evidence that this
|
|
477
553
|
// target is too small, not that every later combo target is incapable of serving it.
|
|
478
554
|
if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop";
|
|
555
|
+
// A definite context-window refusal is target-local inside a heterogeneous combo: this model
|
|
556
|
+
// cannot hold the turn, but a later target may have a larger window. Two boundaries keep this
|
|
557
|
+
// safe. It is reached only after cancellation, structured origin/cyber refusals and
|
|
558
|
+
// non-replayable post-send codes have already stopped. And it only ever classifies a failure
|
|
559
|
+
// the combo stream preflight already proved emitted no output: `comboStreamPayloadCommitsOutput`
|
|
560
|
+
// commits the child on any text, tool call or unknown event, and only a zero-output terminal
|
|
561
|
+
// becomes a failure response at all, so a turn whose text the client already saw is never
|
|
562
|
+
// reclassified here.
|
|
563
|
+
if (isDefiniteContextOverflow(status, message)) return "hop";
|
|
479
564
|
// A local input-admission refusal (#1524) says "this candidate cannot fit the request",
|
|
480
565
|
// not "the request is impossible": the next candidate may have a larger context window.
|
|
481
566
|
//
|