@bitkyc08/opencodex 2.55.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-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- 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/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- 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/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-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- 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 +642 -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 +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -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-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- 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 +566 -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 +431 -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 +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- 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 +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- 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/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -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 +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- 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/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/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/pending-teardown.ts +31 -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 +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -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 +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- 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/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 +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -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/reasoning-envelope.ts +6 -3
- 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 +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -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 +339 -0
- package/src/server/index.ts +45 -2552
- 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/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/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- 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 +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -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 +298 -0
- package/src/server/responses/core.ts +192 -8893
- 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 +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- 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 +34 -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 +147 -1
- package/src/usage/summary.ts +171 -21
- 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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
3
|
+
CODEX_UNKNOWN_USAGE_SCORE,
|
|
4
|
+
resetAtToMs,
|
|
5
|
+
} from "../quota";
|
|
6
|
+
import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
7
|
+
import type { CodexQuotaScope } from "./health-store";
|
|
8
|
+
import type { TransientProbeGrant } from "./thread-affinity";
|
|
9
|
+
|
|
10
|
+
export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
11
|
+
export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
12
|
+
/**
|
|
13
|
+
* A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
|
|
14
|
+
* a "come back after this" directive like Retry-After. Plan quota routinely frees
|
|
15
|
+
* up long before the advertised reset, so cap reset-derived cooldowns far below
|
|
16
|
+
* the Retry-After ceiling (#433).
|
|
17
|
+
*/
|
|
18
|
+
export const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
|
|
19
|
+
/**
|
|
20
|
+
* Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window,
|
|
21
|
+
* tight enough that a weekly or monthly reset four days out cannot take an account out of
|
|
22
|
+
* rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows.
|
|
23
|
+
*/
|
|
24
|
+
export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
|
|
25
|
+
/** Minimum gap between probe leases for one cooled-down account. */
|
|
26
|
+
export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
|
|
27
|
+
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
28
|
+
/**
|
|
29
|
+
* How recently a 100% burst reading must have been OBSERVED to exclude an account when it
|
|
30
|
+
* carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
|
|
31
|
+
* horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
|
|
32
|
+
* reading can never strand a recovered account, and long enough that a snapshot taken at
|
|
33
|
+
* admission is still fresh when selection reads it.
|
|
34
|
+
*/
|
|
35
|
+
export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
|
|
36
|
+
/** How long a transient failure keeps the account out of pool selection. */
|
|
37
|
+
export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
|
|
38
|
+
export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
|
|
39
|
+
CODEX_TRANSIENT_SOFT_AVOID_MS,
|
|
40
|
+
2 * 60_000,
|
|
41
|
+
10 * 60_000,
|
|
42
|
+
30 * 60_000,
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
|
|
46
|
+
export type CodexUpstreamOutcomeClass = "success" | "credential"
|
|
47
|
+
| "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
|
|
48
|
+
export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
49
|
+
|
|
50
|
+
export type CodexUpstreamOutcomeMeta = {
|
|
51
|
+
retryAfter?: string | null;
|
|
52
|
+
resetAt?: unknown | unknown[];
|
|
53
|
+
now?: number;
|
|
54
|
+
/** (provider, host) ledger key for account-neutral reachability failures (#914). */
|
|
55
|
+
hostKey?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
|
|
58
|
+
* is fine and the account simply cannot reach this workspace, so it must not be quarantined
|
|
59
|
+
* for reauthentication (#1789). Absent evidence keeps the historical credential handling.
|
|
60
|
+
*/
|
|
61
|
+
denial?: "workspace" | "entitlement";
|
|
62
|
+
/** Stable transport code recorded alongside a neutral host failure. */
|
|
63
|
+
lastFailureCode?: string;
|
|
64
|
+
/** Native model selected for this request; used only for confirmed scoped quotas. */
|
|
65
|
+
modelId?: string;
|
|
66
|
+
/** When set, clears affinity for this thread immediately on transient failure. */
|
|
67
|
+
threadId?: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
|
|
70
|
+
* request. Credential failures still sweep stale affinities because reauthentication is
|
|
71
|
+
* account-wide.
|
|
72
|
+
*/
|
|
73
|
+
fixedAccount?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Probe lease held by this request, when it was admitted through an active
|
|
76
|
+
* quota cooldown. Only the outcome carrying the current lease may clear the
|
|
77
|
+
* cooldown (#433).
|
|
78
|
+
*/
|
|
79
|
+
probeLeaseId?: string;
|
|
80
|
+
/** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
|
|
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;
|
|
91
|
+
/**
|
|
92
|
+
* Already-chosen alternate for same-request 429 retry. When set, promotion
|
|
93
|
+
* reuses this account instead of calling {@link pickAlternateCodexAccount}
|
|
94
|
+
* again (which would advance a round-robin ring twice).
|
|
95
|
+
*/
|
|
96
|
+
promoteAccountId?: string;
|
|
97
|
+
/** Generation captured when this routed account was selected. */
|
|
98
|
+
writerGeneration?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Credential generation this request's bearer was read at. Distinct from
|
|
101
|
+
* `writerGeneration`, which tracks the config store.
|
|
102
|
+
*
|
|
103
|
+
* A 401 that arrives after the credential was already replaced is evidence about a
|
|
104
|
+
* token nobody is using any more, so it must not quarantine the replacement. Absent
|
|
105
|
+
* means the caller cannot supply lineage and the historical unfenced handling stands.
|
|
106
|
+
*/
|
|
107
|
+
credentialGeneration?: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function computeCodexUsageScore(quota: {
|
|
111
|
+
weeklyPercent?: number;
|
|
112
|
+
monthlyPercent?: number;
|
|
113
|
+
shortPercent?: number;
|
|
114
|
+
shortResetAt?: number;
|
|
115
|
+
shortObservedAt?: number;
|
|
116
|
+
} | null, plan?: unknown, now: number = Date.now()): number {
|
|
117
|
+
if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
|
|
118
|
+
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
119
|
+
const longWindows = isThirtyDayOnlyCodexPlan(plan)
|
|
120
|
+
? [quota.monthlyPercent]
|
|
121
|
+
: [quota.weeklyPercent, quota.monthlyPercent];
|
|
122
|
+
const knownLong = longWindows.filter(finite);
|
|
123
|
+
// The short burst window only REFINES a known long-window position; it cannot stand in for
|
|
124
|
+
// one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an
|
|
125
|
+
// account whose weekly/monthly usage is entirely unverified look like the emptiest in the
|
|
126
|
+
// pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
|
|
127
|
+
// unknown until a governing window is actually observed.
|
|
128
|
+
//
|
|
129
|
+
// A FULL burst window is the exception (#3029). It is not an optimistic guess about an
|
|
130
|
+
// unobserved window — it is a direct observation that the account cannot serve a request
|
|
131
|
+
// right now, whatever its monthly position turns out to be. Unknown-means-selectable is
|
|
132
|
+
// correct for uncertainty and wrong for a measured refusal: the account stays selected,
|
|
133
|
+
// `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential.
|
|
134
|
+
if (knownLong.length === 0) {
|
|
135
|
+
return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE;
|
|
136
|
+
}
|
|
137
|
+
const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
|
|
138
|
+
return Math.max(...values);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A short-only reading that proves the account is blocked NOW.
|
|
143
|
+
*
|
|
144
|
+
* Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
|
|
145
|
+
* carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
|
|
146
|
+
* hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
|
|
147
|
+
* five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
|
|
148
|
+
* explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
|
|
149
|
+
* That is #3029 pointed the other way: the issue is that
|
|
150
|
+
* an exhausted account stays selected, and "a recovered account stays excluded" trades one
|
|
151
|
+
* unusable pool for another.
|
|
152
|
+
*
|
|
153
|
+
* A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
|
|
154
|
+
* direction here is the one that keeps an account selectable: a wrongly-selected account
|
|
155
|
+
* fails one request, while a wrongly-excluded one is invisible until someone reads the pool
|
|
156
|
+
* by hand.
|
|
157
|
+
*
|
|
158
|
+
* A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
|
|
159
|
+
* sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
|
|
160
|
+
* Old disk snapshots without short-window provenance remain unknown.
|
|
161
|
+
*/
|
|
162
|
+
function isTerminalShortWindow(
|
|
163
|
+
quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
|
|
164
|
+
now: number,
|
|
165
|
+
): boolean {
|
|
166
|
+
if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
|
|
167
|
+
if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
|
|
168
|
+
const resetAt = quota.shortResetAt;
|
|
169
|
+
if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
|
|
170
|
+
const observedAt = quota.shortObservedAt;
|
|
171
|
+
if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
|
|
172
|
+
const age = now - observedAt;
|
|
173
|
+
return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
|
|
174
|
+
}
|
|
175
|
+
// Seconds and milliseconds both reach storage, so the split lives in one place next to the
|
|
176
|
+
// merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
|
|
177
|
+
return resetAtToMs(resetAt) > now;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function classifyCodexUpstreamOutcome(
|
|
181
|
+
outcome: CodexUpstreamOutcome,
|
|
182
|
+
denial?: "workspace" | "entitlement",
|
|
183
|
+
): CodexUpstreamOutcomeClass {
|
|
184
|
+
if (outcome === "connect_neutral") return "neutral";
|
|
185
|
+
if (outcome === "connect_error" || outcome === "timeout") return "transient";
|
|
186
|
+
if (!Number.isFinite(outcome)) return "unknown";
|
|
187
|
+
if (outcome >= 200 && outcome < 300) return "success";
|
|
188
|
+
// Explicit 3xx policy (#914): a redirect response is relayed as-is and is
|
|
189
|
+
// never account or host health evidence — it proves the host is reachable
|
|
190
|
+
// and says nothing about the credential. Relayed as the neutral class so a
|
|
191
|
+
// stray 3xx cannot increment an account's transient streak.
|
|
192
|
+
if (outcome >= 300 && outcome < 400) return "neutral";
|
|
193
|
+
// 401 is always a credential problem. A 403 is only a credential problem when nothing
|
|
194
|
+
// tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
|
|
195
|
+
// and the account simply lacks access here, so quarantining it for reauth is wrong advice.
|
|
196
|
+
// Absent denial evidence the historical mapping stands, so the change fails safe.
|
|
197
|
+
if (outcome === 403 && denial !== undefined) return "workspace";
|
|
198
|
+
if (outcome === 401 || outcome === 403) return "credential";
|
|
199
|
+
// 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
|
|
200
|
+
// (same-request alternate retry records this outcome for the depleted account).
|
|
201
|
+
if (outcome === 429 || outcome === 402) return "quota";
|
|
202
|
+
if (outcome >= 400 && outcome < 500) return "caller";
|
|
203
|
+
if (outcome >= 500 && outcome < 600) return "transient";
|
|
204
|
+
return "unknown";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clampCooldownMs(ms: number): number {
|
|
208
|
+
return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
|
|
212
|
+
const text = value?.trim();
|
|
213
|
+
if (!text) return undefined;
|
|
214
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
215
|
+
const seconds = Number(text);
|
|
216
|
+
if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
|
|
217
|
+
}
|
|
218
|
+
const timestamp = Date.parse(text);
|
|
219
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
220
|
+
const delay = timestamp - now;
|
|
221
|
+
return delay > 0 ? clampCooldownMs(delay) : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resetTimestampMs(value: unknown): number | undefined {
|
|
225
|
+
const numeric = typeof value === "number"
|
|
226
|
+
? value
|
|
227
|
+
: typeof value === "string" && value.trim() !== ""
|
|
228
|
+
? Number(value)
|
|
229
|
+
: undefined;
|
|
230
|
+
if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
|
|
231
|
+
return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
|
|
235
|
+
const values = Array.isArray(resetAt) ? resetAt : [resetAt];
|
|
236
|
+
let best: number | undefined;
|
|
237
|
+
for (const value of values) {
|
|
238
|
+
const timestamp = resetTimestampMs(value);
|
|
239
|
+
if (timestamp === undefined) continue;
|
|
240
|
+
const delay = timestamp - now;
|
|
241
|
+
if (delay <= 0) continue;
|
|
242
|
+
// A far-future reset must not pin the account for the full Retry-After
|
|
243
|
+
// ceiling: quota usually frees up well before the advertised window (#433).
|
|
244
|
+
const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
|
|
245
|
+
if (best === undefined || clamped < best) best = clamped;
|
|
246
|
+
}
|
|
247
|
+
return best;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
|
|
251
|
+
until: number;
|
|
252
|
+
source: CodexCooldownSource;
|
|
253
|
+
} {
|
|
254
|
+
const now = meta.now ?? Date.now();
|
|
255
|
+
const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
|
|
256
|
+
if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
|
|
257
|
+
const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
|
|
258
|
+
if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
|
|
259
|
+
return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* When the pool should stop preferring an account after it refused on quota.
|
|
264
|
+
*
|
|
265
|
+
* The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS},
|
|
266
|
+
* and never shorter than the cooldown the same refusal produced — a Retry-After directive that
|
|
267
|
+
* outlasts every announcement still governs.
|
|
268
|
+
*/
|
|
269
|
+
export function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number {
|
|
270
|
+
const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt];
|
|
271
|
+
let announced: number | undefined;
|
|
272
|
+
for (const value of values) {
|
|
273
|
+
const timestamp = resetTimestampMs(value);
|
|
274
|
+
if (timestamp === undefined) continue;
|
|
275
|
+
const delay = timestamp - now;
|
|
276
|
+
if (delay <= 0) continue;
|
|
277
|
+
const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS);
|
|
278
|
+
if (announced === undefined || until < announced) announced = until;
|
|
279
|
+
}
|
|
280
|
+
return Math.max(cooldownUntil, announced ?? 0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
|
|
284
|
+
return computeQuotaCooldown(meta).until;
|
|
285
|
+
}
|
|
@@ -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
|
+
}
|