@bitkyc08/opencodex 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { isCodexAccountGenerationLive } from "../account-store";
|
|
2
|
+
import { NATIVE_RESERVE_MODEL } from "../catalog/native-models";
|
|
3
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
4
|
+
import { POOL_KEY_CODEX } from "../pool-rotation";
|
|
5
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
6
|
+
import type { OcxConfig } from "../../types";
|
|
7
|
+
import type { CodexCooldownSource } from "./cooldown-math";
|
|
8
|
+
|
|
9
|
+
export type CodexUpstreamHealth = {
|
|
10
|
+
consecutiveFailures: number;
|
|
11
|
+
/** Consecutive healthy terminals observed while recovering from escalation level 2+. */
|
|
12
|
+
consecutiveSuccesses?: number;
|
|
13
|
+
lastFailureStatus?: number;
|
|
14
|
+
lastFailureAt?: number;
|
|
15
|
+
/** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
|
|
16
|
+
cooldownUntil?: number;
|
|
17
|
+
/**
|
|
18
|
+
* How long a quota refusal keeps selection away from this account (or this native quota
|
|
19
|
+
* group), as opposed to how long it is hard-blocked.
|
|
20
|
+
*
|
|
21
|
+
* The two are deliberately different lengths. {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS}
|
|
22
|
+
* caps the hard cooldown at 15 minutes because a reset announcement is advisory and plan
|
|
23
|
+
* quota usually frees up before it — an account must stay reachable so the pool can find
|
|
24
|
+
* that out (#433). The window the refusal announced is not 15 minutes, though, so once the
|
|
25
|
+
* cooldown lapses the account is selectable again while its burst window is still spent,
|
|
26
|
+
* and the strategy picks it straight back: this proxy reads a weekly bar a burst limit never
|
|
27
|
+
* touches, so a refused account still scores as the coolest in the pool. Every request then
|
|
28
|
+
* earns the same 429 until the process restarts, which is the only thing that drops this map.
|
|
29
|
+
*
|
|
30
|
+
* So the announcement governs avoidance and the cap still governs blocking. Avoidance is soft
|
|
31
|
+
* in the {@link softAvoidUntil} sense: it reorders the pool and releases a bound thread, and
|
|
32
|
+
* the last-resort paths still reach the account when nothing else can serve, so one pessimistic
|
|
33
|
+
* announcement cannot stall routing.
|
|
34
|
+
*/
|
|
35
|
+
quotaAvoidUntil?: number;
|
|
36
|
+
/** When the current cooldown was recorded; origin of the probe interval clock. */
|
|
37
|
+
cooldownSince?: number;
|
|
38
|
+
/**
|
|
39
|
+
* What produced the cooldown. An explicit Retry-After is a literal retry
|
|
40
|
+
* directive and is never probed; a quota resetAt only announces a window
|
|
41
|
+
* refresh, so it may be probed early (#433).
|
|
42
|
+
*/
|
|
43
|
+
cooldownSource?: CodexCooldownSource;
|
|
44
|
+
/**
|
|
45
|
+
* Bumped on every cooldown write. A probe lease records the generation it was
|
|
46
|
+
* issued for so a lease cannot clear a cooldown that a later 429 replaced.
|
|
47
|
+
*/
|
|
48
|
+
cooldownGeneration?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Identity of the in-flight probe. A cooled-down account sends no traffic, so
|
|
51
|
+
* no organic 2xx can prove recovery; only the outcome carrying this id may
|
|
52
|
+
* clear the cooldown.
|
|
53
|
+
*/
|
|
54
|
+
probeLeaseId?: string;
|
|
55
|
+
/** Cooldown generation at the moment the lease was granted. */
|
|
56
|
+
probeLeaseGeneration?: number;
|
|
57
|
+
/** Last probe grant or conclusion; paces the probe interval. */
|
|
58
|
+
lastProbeAt?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
|
|
61
|
+
* Blocks pool selection + thread affinity reuse so a sticky session can leave a
|
|
62
|
+
* flaky account without throwing CodexAccountCooldownError (hard-only).
|
|
63
|
+
*/
|
|
64
|
+
softAvoidUntil?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Credential generation a 401/403 quarantine was derived from (#2892 gap 4).
|
|
67
|
+
*
|
|
68
|
+
* Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends
|
|
69
|
+
* "whatever health is current when the old credential is found dead", which deletes a later
|
|
70
|
+
* unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the
|
|
71
|
+
* entry that carries this field can be spent, and any later write simply replaces it.
|
|
72
|
+
*/
|
|
73
|
+
credentialFailureGeneration?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
77
|
+
/**
|
|
78
|
+
* Reset-derived 429s can describe a quota owned by one native model family,
|
|
79
|
+
* rather than the whole ChatGPT account. Keep those advisory cooldowns apart
|
|
80
|
+
* from account-wide Retry-After/default throttles and transient health.
|
|
81
|
+
*/
|
|
82
|
+
const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
|
|
83
|
+
/**
|
|
84
|
+
* Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4).
|
|
85
|
+
*
|
|
86
|
+
* A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after
|
|
87
|
+
* the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the
|
|
88
|
+
* window without closing it. The reader decides instead, and it may only spend an entry that
|
|
89
|
+
* actually carries credential provenance: a later transient or quota write replaces the entry and
|
|
90
|
+
* with it the tag, so this can never delete evidence that belongs to a different failure.
|
|
91
|
+
*/
|
|
92
|
+
export function dropSpentCredentialFailure(accountId: string): void {
|
|
93
|
+
const health = upstreamHealth.get(accountId);
|
|
94
|
+
const generation = health?.credentialFailureGeneration;
|
|
95
|
+
if (health === undefined || generation === undefined) return;
|
|
96
|
+
if (isCodexAccountGenerationLive(accountId, generation)) return;
|
|
97
|
+
upstreamHealth.delete(accountId);
|
|
98
|
+
}
|
|
99
|
+
let lastReconciledGeneration = 0;
|
|
100
|
+
let liveHealthAccountIds = new Set<string>();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Native Codex quota groups known to be independent upstream. Keep the mapping
|
|
104
|
+
* deliberately conservative: unlisted models share the normal native group.
|
|
105
|
+
* Add a new explicit group here only when its independent upstream quota is
|
|
106
|
+
* confirmed, so shared limits never receive cross-model bypasses.
|
|
107
|
+
*/
|
|
108
|
+
export type CodexQuotaScope = "shared" | "reserve";
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
export const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
|
|
112
|
+
[NATIVE_RESERVE_MODEL]: "reserve",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
|
|
116
|
+
if (!modelId?.trim()) return undefined;
|
|
117
|
+
return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Independent quota groups must not mutate the shared active-account cursor. */
|
|
121
|
+
export function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean {
|
|
122
|
+
return quotaScope !== undefined && quotaScope !== "shared";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string {
|
|
126
|
+
return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
|
|
130
|
+
const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
|
|
131
|
+
const openai = config.providers.openai;
|
|
132
|
+
if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
|
|
133
|
+
ids.add(MAIN_CODEX_ACCOUNT_ID);
|
|
134
|
+
}
|
|
135
|
+
return ids;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getCodexUpstreamHealth(
|
|
139
|
+
accountId: string,
|
|
140
|
+
): CodexUpstreamHealth | null {
|
|
141
|
+
dropSpentCredentialFailure(accountId);
|
|
142
|
+
return upstreamHealth.get(accountId) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined {
|
|
146
|
+
return quotaScopedHealth.get(accountId)?.get(scope);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void {
|
|
150
|
+
let scopes = quotaScopedHealth.get(accountId);
|
|
151
|
+
if (!scopes) {
|
|
152
|
+
scopes = new Map();
|
|
153
|
+
quotaScopedHealth.set(accountId, scopes);
|
|
154
|
+
}
|
|
155
|
+
scopes.set(scope, health);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
|
|
159
|
+
const scopes = quotaScopedHealth.get(accountId);
|
|
160
|
+
if (!scopes) return;
|
|
161
|
+
scopes.delete(scope);
|
|
162
|
+
if (scopes.size === 0) quotaScopedHealth.delete(accountId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Live quota-refusal avoidance for an account, including the lane the request belongs to. */
|
|
166
|
+
function codexQuotaAvoidUntil(
|
|
167
|
+
accountId: string,
|
|
168
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
169
|
+
now: number,
|
|
170
|
+
): number | null {
|
|
171
|
+
const live = (value: number | undefined): number | null =>
|
|
172
|
+
typeof value === "number" && Number.isFinite(value) && value > now ? value : null;
|
|
173
|
+
const account = live(upstreamHealth.get(accountId)?.quotaAvoidUntil);
|
|
174
|
+
const scoped = quotaScope === undefined
|
|
175
|
+
? null
|
|
176
|
+
: live(scopedHealthFor(accountId, quotaScope)?.quotaAvoidUntil);
|
|
177
|
+
if (account === null) return scoped;
|
|
178
|
+
return scoped === null ? account : Math.max(account, scoped);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isCodexQuotaAvoided(
|
|
182
|
+
accountId: string,
|
|
183
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
184
|
+
now: number,
|
|
185
|
+
): boolean {
|
|
186
|
+
return codexQuotaAvoidUntil(accountId, quotaScope, now) !== null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild
|
|
191
|
+
* their health object from. Dropping these would let one late unrelated response
|
|
192
|
+
* erase a Retry-After source, a cooldown generation, or someone else's live probe.
|
|
193
|
+
*/
|
|
194
|
+
export function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
|
|
195
|
+
if (!health) return {};
|
|
196
|
+
// `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive
|
|
197
|
+
// into a later transient or quota entry — otherwise that entry inherits the tag and gets spent
|
|
198
|
+
// when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review).
|
|
199
|
+
const {
|
|
200
|
+
consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at,
|
|
201
|
+
softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields
|
|
202
|
+
} = health;
|
|
203
|
+
return cooldownFields;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
|
|
207
|
+
const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
|
|
208
|
+
return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
|
|
212
|
+
export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
|
|
213
|
+
cooldownUntil?: number;
|
|
214
|
+
cooldownSource?: CodexCooldownSource;
|
|
215
|
+
} | null {
|
|
216
|
+
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
217
|
+
if (cooldownUntil === null) return null;
|
|
218
|
+
const source = upstreamHealth.get(accountId)?.cooldownSource;
|
|
219
|
+
return {
|
|
220
|
+
cooldownUntil,
|
|
221
|
+
...(source ? { cooldownSource: source } : {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read the cooldown relevant to a routed native model. Account-wide cooldowns
|
|
227
|
+
* (Retry-After/default) always win; reset-derived scoped state applies only to
|
|
228
|
+
* its confirmed quota group.
|
|
229
|
+
*/
|
|
230
|
+
export function getCodexQuotaHealthSnapshot(
|
|
231
|
+
accountId: string,
|
|
232
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
233
|
+
now = Date.now(),
|
|
234
|
+
): {
|
|
235
|
+
cooldownUntil?: number;
|
|
236
|
+
cooldownSource?: CodexCooldownSource;
|
|
237
|
+
quotaScope?: CodexQuotaScope;
|
|
238
|
+
} | null {
|
|
239
|
+
const account = getCodexAccountHealthSnapshot(accountId, now);
|
|
240
|
+
if (account) return account;
|
|
241
|
+
if (!quotaScope) return null;
|
|
242
|
+
const scoped = scopedHealthFor(accountId, quotaScope);
|
|
243
|
+
const cooldownUntil = scoped?.cooldownUntil;
|
|
244
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
|
|
245
|
+
return {
|
|
246
|
+
cooldownUntil,
|
|
247
|
+
...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}),
|
|
248
|
+
quotaScope,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
|
|
253
|
+
return getCodexAccountCooldownUntil(accountId, now) !== null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Manually lift a hard quota cooldown without touching failure history.
|
|
258
|
+
*
|
|
259
|
+
* Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
|
|
260
|
+
* cooldown that outlives the real upstream limit reads to the user as "the whole app is
|
|
261
|
+
* broken" with no escape but editing config.toml. This is that escape hatch.
|
|
262
|
+
*
|
|
263
|
+
* Deliberately narrow:
|
|
264
|
+
* - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
|
|
265
|
+
* moved", not "this account is healthy"; failover must keep its knowledge.
|
|
266
|
+
* - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
|
|
267
|
+
* recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
|
|
268
|
+
* `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
|
|
269
|
+
* bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
|
|
270
|
+
* today and is kept so the invariant survives a future change that retains the lease.
|
|
271
|
+
*
|
|
272
|
+
* Returns false when the account carried neither a live cooldown nor a live avoidance window.
|
|
273
|
+
* The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the
|
|
274
|
+
* window runs up to six hours — so the moment an operator actually reaches for this escape
|
|
275
|
+
* hatch is usually after the cooldown lapsed and only the window is still keeping the account
|
|
276
|
+
* out of rotation. Refusing to look at the window then would leave the hatch shut in the one
|
|
277
|
+
* case it exists for.
|
|
278
|
+
*/
|
|
279
|
+
export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
|
|
280
|
+
const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
|
|
281
|
+
const cooldownUntil = health.cooldownUntil;
|
|
282
|
+
const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now;
|
|
283
|
+
const avoidUntil = health.quotaAvoidUntil;
|
|
284
|
+
const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now;
|
|
285
|
+
if (!liveCooldown && !liveAvoidance) return null;
|
|
286
|
+
const {
|
|
287
|
+
cooldownUntil: _until,
|
|
288
|
+
cooldownSince: _since,
|
|
289
|
+
cooldownSource: _source,
|
|
290
|
+
probeLeaseId: _leaseId,
|
|
291
|
+
probeLeaseGeneration: _leaseGeneration,
|
|
292
|
+
// Same reasoning as the probe recovery above: "the quota window moved" is a statement
|
|
293
|
+
// about the whole refusal, so the avoidance it announced goes with the block it
|
|
294
|
+
// produced. Keeping it would leave this escape hatch not escaping, because selection
|
|
295
|
+
// would still pass over the account for as long as the announced window runs.
|
|
296
|
+
quotaAvoidUntil: _avoid,
|
|
297
|
+
...rest
|
|
298
|
+
} = health;
|
|
299
|
+
return {
|
|
300
|
+
...rest,
|
|
301
|
+
cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
|
|
302
|
+
lastProbeAt: now,
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
let cleared = false;
|
|
307
|
+
const accountHealth = upstreamHealth.get(accountId);
|
|
308
|
+
if (accountHealth) {
|
|
309
|
+
const next = clear(accountHealth);
|
|
310
|
+
if (next) {
|
|
311
|
+
upstreamHealth.set(accountId, next);
|
|
312
|
+
cleared = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) {
|
|
316
|
+
const next = clear(health);
|
|
317
|
+
if (next) {
|
|
318
|
+
setScopedHealth(accountId, scope, next);
|
|
319
|
+
cleared = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return cleared;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
|
|
326
|
+
const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
|
|
327
|
+
return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
|
|
328
|
+
? softAvoidUntil
|
|
329
|
+
: null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
|
|
333
|
+
return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Closed package-internal accessors for the account-wide health maps. Selection,
|
|
338
|
+
* the probe lease, and the active cursor mutate health only through these; the
|
|
339
|
+
* Map bindings themselves never leave this module.
|
|
340
|
+
*/
|
|
341
|
+
export function getAccountHealth(accountId: string): CodexUpstreamHealth | undefined {
|
|
342
|
+
return upstreamHealth.get(accountId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function setAccountHealth(accountId: string, health: CodexUpstreamHealth): void {
|
|
346
|
+
upstreamHealth.set(accountId, health);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function deleteAccountHealth(accountId: string): void {
|
|
350
|
+
upstreamHealth.delete(accountId);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> {
|
|
354
|
+
return [...(quotaScopedHealth.get(accountId) ?? [])];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function deleteAllScopedHealth(accountId: string): void {
|
|
358
|
+
quotaScopedHealth.delete(accountId);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function isHealthAccountAdmissible(accountId: string, writerGeneration: number): boolean {
|
|
362
|
+
return writerGeneration >= lastReconciledGeneration || liveHealthAccountIds.has(accountId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function isHealthGenerationReconciled(generation: number): boolean {
|
|
366
|
+
return generation <= lastReconciledGeneration;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function pruneHealthAccountsForContext(codexAccountIds: ReadonlySet<string>): number {
|
|
370
|
+
let removed = 0;
|
|
371
|
+
for (const accountId of upstreamHealth.keys()) {
|
|
372
|
+
if (codexAccountIds.has(accountId)) continue;
|
|
373
|
+
upstreamHealth.delete(accountId);
|
|
374
|
+
removed += 1;
|
|
375
|
+
}
|
|
376
|
+
for (const accountId of quotaScopedHealth.keys()) {
|
|
377
|
+
if (codexAccountIds.has(accountId)) continue;
|
|
378
|
+
quotaScopedHealth.delete(accountId);
|
|
379
|
+
removed += 1;
|
|
380
|
+
}
|
|
381
|
+
return removed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function commitHealthReconcile(generation: number, codexAccountIds: ReadonlySet<string>): void {
|
|
385
|
+
liveHealthAccountIds = new Set(codexAccountIds);
|
|
386
|
+
lastReconciledGeneration = generation;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function clearUpstreamHealthState(): void {
|
|
390
|
+
upstreamHealth.clear();
|
|
391
|
+
quotaScopedHealth.clear();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function resetHealthReconcileState(): void {
|
|
395
|
+
lastReconciledGeneration = 0;
|
|
396
|
+
liveHealthAccountIds = new Set();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function deleteAllHealthForAccount(accountId: string): void {
|
|
400
|
+
upstreamHealth.delete(accountId);
|
|
401
|
+
quotaScopedHealth.delete(accountId);
|
|
402
|
+
}
|