@bitkyc08/opencodex 2.89.0 → 2.90.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/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -13,7 +13,7 @@ import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
|
13
13
|
import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
|
|
14
14
|
import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
|
|
15
15
|
import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
|
|
16
|
-
import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
|
|
16
|
+
import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock";
|
|
17
17
|
import type { OcxConfig } from "../../types";
|
|
18
18
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
19
19
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
@@ -78,8 +78,55 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
|
|
|
78
78
|
|
|
79
79
|
const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;
|
|
80
80
|
|
|
81
|
+
/** Traffic headers renew usage, never the independent credit observation (#7377). */
|
|
82
|
+
function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean {
|
|
83
|
+
const account = configuredPoolAccount(config, accountId);
|
|
84
|
+
if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId)
|
|
85
|
+
|| isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
|
|
86
|
+
const quota = getAccountQuota(accountId);
|
|
87
|
+
if (codexUsageLimitResetAt(quota, account.plan, now) === undefined) return false;
|
|
88
|
+
const observedAt = quota?.credits?.observedAt;
|
|
89
|
+
if (observedAt !== undefined && Number.isFinite(observedAt)
|
|
90
|
+
&& now - observedAt >= 0 && now - observedAt < MAIN_CREDIT_RENEWAL_AGE_MS) return false;
|
|
91
|
+
const attempt = poolQuotaPrimeAttemptedAt.get(accountId);
|
|
92
|
+
return !attempt || attempt.generation !== readCodexAccountRecord(accountId)?.generation
|
|
93
|
+
|| now - attempt.at >= MAIN_CREDIT_RENEWAL_AGE_MS;
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
let mainHardLockRecoveryInFlight: Promise<void> | null = null;
|
|
82
|
-
|
|
97
|
+
/** `floor` and `resetBound` apply only while a predicted reset is (or just was) holding the lock. */
|
|
98
|
+
type MainRecoveryAttempt = { identity: number; credential: number; after: number; delay: number; floor?: number; resetBound?: number };
|
|
99
|
+
let mainHardLockRecoveryAttempt: MainRecoveryAttempt | undefined;
|
|
100
|
+
/** The identity/credential that already waited out its first hold interval. */
|
|
101
|
+
let mainHardLockHoldSeen: { identity: number; credential: number } | undefined;
|
|
102
|
+
const MAIN_HOLD_MIN_INTERVAL_MS = nextQuotaQueryDelay();
|
|
103
|
+
|
|
104
|
+
/** The predicted reset of a blocked main, or undefined when no future reset is known. */
|
|
105
|
+
function predictedMainResetHold(status: ReturnType<typeof getMainAccountHardLockStatus>): number | undefined {
|
|
106
|
+
return status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()
|
|
107
|
+
? status.resetAt : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A hold read is due at its backoff deadline, pulled in to the latest known predicted reset (even
|
|
112
|
+
* one that moved earlier or just passed) but never sooner than one base interval after the last read.
|
|
113
|
+
*/
|
|
114
|
+
function mainRecoveryDueAt(attempt: MainRecoveryAttempt, holdResetAt: number | undefined, config: OcxConfig): number {
|
|
115
|
+
// Once a hold elapses the status omits its reset; current evidence may still name a revised one.
|
|
116
|
+
const bound = holdResetAt ?? (attempt.resetBound === undefined ? undefined
|
|
117
|
+
: Math.min(attempt.resetBound, latestMainBlockingResetAt(config) ?? Infinity));
|
|
118
|
+
return bound === undefined ? attempt.after : Math.max(attempt.floor ?? 0, Math.min(attempt.after, bound));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** First sight of a hold for this credential waits one base interval; transient retries do not re-arm it. */
|
|
122
|
+
function armMainResetHold(identity: number, credential: number, holdResetAt: number): void {
|
|
123
|
+
const at = Date.now() + MAIN_HOLD_MIN_INTERVAL_MS;
|
|
124
|
+
mainHardLockHoldSeen = { identity, credential };
|
|
125
|
+
mainHardLockRecoveryAttempt = { identity, credential, delay: MAIN_HOLD_MIN_INTERVAL_MS, after: at, floor: at, resetBound: holdResetAt };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const mainHoldSeenFor = (identity: number, credential: number): boolean =>
|
|
129
|
+
mainHardLockHoldSeen?.identity === identity && mainHardLockHoldSeen.credential === credential;
|
|
83
130
|
|
|
84
131
|
/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
|
|
85
132
|
export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
|
|
@@ -98,10 +145,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
98
145
|
|| (observedAt !== undefined && now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS));
|
|
99
146
|
};
|
|
100
147
|
const status = getMainAccountHardLockStatus(config);
|
|
148
|
+
if (status.state !== "blocked") mainHardLockHoldSeen = undefined;
|
|
101
149
|
if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; }
|
|
102
150
|
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
103
|
-
// A predicted reset is a scheduling hint, never proof that the hard lock can be lifted.
|
|
104
|
-
|
|
151
|
+
// A predicted reset is a scheduling hint, never proof that the hard lock can be lifted. The
|
|
152
|
+
// hold is rechecked on the capped WHAM schedule so an early reset redeemed elsewhere is
|
|
153
|
+
// learned from metadata (#7312); a paused main receives no hold rechecks.
|
|
154
|
+
const holdResetAt = predictedMainResetHold(status);
|
|
155
|
+
if (holdResetAt !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)) return;
|
|
105
156
|
// Reconcile the owned physical credential before skipping work for an older one.
|
|
106
157
|
reconcileMainCodexAccountRuntimeState();
|
|
107
158
|
const physical = readCodexTokens();
|
|
@@ -109,9 +160,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
109
160
|
const identity = captureMainAccountIdentityGeneration();
|
|
110
161
|
const credential = getMainQuotaCredentialGeneration();
|
|
111
162
|
const previous = mainHardLockRecoveryAttempt;
|
|
163
|
+
if (holdResetAt !== undefined && !mainHoldSeenFor(identity, credential)) {
|
|
164
|
+
armMainResetHold(identity, credential, holdResetAt);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const sameAttempt = previous?.identity === identity && previous.credential === credential;
|
|
168
|
+
if (sameAttempt && holdResetAt !== undefined) previous.resetBound = holdResetAt;
|
|
112
169
|
const queryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
|
|
113
|
-
if ((previous
|
|
114
|
-
&& previous.after > Date.now()) || queryAfter > Date.now()) return;
|
|
170
|
+
if ((sameAttempt && mainRecoveryDueAt(previous, holdResetAt, config) > Date.now()) || queryAfter > Date.now()) return;
|
|
115
171
|
const lease = tryAcquireNativeMainProfileClaim();
|
|
116
172
|
if (!lease) return;
|
|
117
173
|
mainHardLockRecoveryInFlight = (async () => {
|
|
@@ -130,16 +186,20 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
130
186
|
// Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
|
|
131
187
|
return;
|
|
132
188
|
}
|
|
133
|
-
const
|
|
189
|
+
const currentHold = predictedMainResetHold(getMainAccountHardLockStatus(config));
|
|
134
190
|
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|
|
135
191
|
|| !isMainAccountIdentityGenerationLive(identityGeneration)
|
|
136
192
|
|| (passive && !creditRecoveryNeeded())
|
|
137
|
-
|| (
|
|
138
|
-
&& currentStatus.resetAt > Date.now())) return;
|
|
193
|
+
|| (currentHold !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID))) return;
|
|
139
194
|
const credential = getMainQuotaCredentialGeneration();
|
|
195
|
+
// A hold that appeared during preparation is first seen now and waits its base interval.
|
|
196
|
+
if (currentHold !== undefined && !mainHoldSeenFor(identityGeneration, credential)) {
|
|
197
|
+
armMainResetHold(identityGeneration, credential, currentHold);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
140
200
|
// Reconcile the physical bearer before consulting the previous credential's delay.
|
|
141
201
|
if (previous?.identity === identityGeneration && previous.credential === credential
|
|
142
|
-
&& previous
|
|
202
|
+
&& mainRecoveryDueAt(previous, currentHold, config) > Date.now()) return;
|
|
143
203
|
const currentQueryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
|
|
144
204
|
if (currentQueryAfter > Date.now()) {
|
|
145
205
|
mainHardLockRecoveryAttempt = { identity: identityGeneration, credential,
|
|
@@ -162,8 +222,10 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
|
|
|
162
222
|
if (transientAuth) mainHardLockRecoveryAttempt = undefined;
|
|
163
223
|
else {
|
|
164
224
|
const delay = nextQuotaQueryDelay(previousDelay || undefined);
|
|
225
|
+
const hold = predictedMainResetHold(getMainAccountHardLockStatus(config));
|
|
165
226
|
mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded()
|
|
166
|
-
? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter)
|
|
227
|
+
? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter),
|
|
228
|
+
...(hold !== undefined ? { floor: Date.now() + MAIN_HOLD_MIN_INTERVAL_MS, resetBound: hold } : {}) }
|
|
167
229
|
: undefined;
|
|
168
230
|
}
|
|
169
231
|
} else if (queryAfter > Date.now()) {
|
|
@@ -186,6 +248,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
|
|
|
186
248
|
afterTick: () => {
|
|
187
249
|
void runCodexCooldownRecoveryProbes(config);
|
|
188
250
|
void runMainAccountHardLockRecovery(config);
|
|
251
|
+
void primeCodexPoolQuotas(config, "credit-renewal");
|
|
189
252
|
},
|
|
190
253
|
});
|
|
191
254
|
}
|
|
@@ -265,6 +328,7 @@ export async function primeCodexPoolQuotas(
|
|
|
265
328
|
primeInFlight = trackBackgroundWork(`codex-pool-quota-prime:${reason}`, (async () => {
|
|
266
329
|
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
267
330
|
const stale = pool.filter(a => {
|
|
331
|
+
if (reason === "credit-renewal") return poolCreditRenewalDue(runtimeConfig, a.id);
|
|
268
332
|
const q = getAccountQuota(a.id);
|
|
269
333
|
const observationStale = reason === "priority-failback" && q
|
|
270
334
|
&& !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
|
|
@@ -278,7 +342,10 @@ export async function primeCodexPoolQuotas(
|
|
|
278
342
|
if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
|
|
279
343
|
return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
|
|
280
344
|
});
|
|
345
|
+
if (reason === "credit-renewal" && stale.length === 0) return;
|
|
281
346
|
const primeMain = async () => {
|
|
347
|
+
// Main renewal owns its physical-identity claims and passive probe separately.
|
|
348
|
+
if (reason === "credit-renewal") return;
|
|
282
349
|
const mainLease = tryAcquireNativeMainPrimeLease();
|
|
283
350
|
if (!mainLease) return;
|
|
284
351
|
try {
|
|
@@ -311,11 +378,12 @@ export async function primeCodexPoolQuotas(
|
|
|
311
378
|
primeMain(),
|
|
312
379
|
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
313
380
|
if (!getCodexAccountCredential(a.id)) return;
|
|
381
|
+
if (reason === "credit-renewal" && !poolCreditRenewalDue(getRuntimeConfig(config), a.id)) return;
|
|
314
382
|
let result: PoolQuotaResult;
|
|
315
383
|
try {
|
|
316
384
|
const quota = getAccountQuota(a.id);
|
|
317
|
-
const bypassCachedQuota = reason === "priority-failback" && quota !== null
|
|
318
|
-
&& !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
|
|
385
|
+
const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null
|
|
386
|
+
&& !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS));
|
|
319
387
|
result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
|
|
320
388
|
} catch (error) {
|
|
321
389
|
// Local quota-flight saturation proves no WHAM request existed for this account.
|
|
@@ -4,8 +4,9 @@ import { getCodexAccountCredential } from "./account-store";
|
|
|
4
4
|
import { loadConfig } from "../config";
|
|
5
5
|
import { resolveCodexHomeDir } from "./home";
|
|
6
6
|
import { extractAccountId } from "../oauth/chatgpt";
|
|
7
|
-
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
7
|
+
import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
8
8
|
import { codexPlanKey } from "./plan";
|
|
9
|
+
import { getObservedMainCodexCollisionIdentity, sameCodexCollisionIdentity, type CodexCollisionIdentity } from "./collision-identity";
|
|
9
10
|
import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
|
|
10
11
|
|
|
11
12
|
export interface CodexTokens {
|
|
@@ -94,17 +95,23 @@ function isWorkspacePlan(plan: unknown): boolean {
|
|
|
94
95
|
return !!key && /team|business|enterprise|workspace|edu/.test(key);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
// Main login and
|
|
98
|
-
//
|
|
99
|
-
// Within each pool bucket, keep the original ChatGPT account id + email collision guard.
|
|
98
|
+
// Main login and pool accounts share the account id + email collision guard.
|
|
99
|
+
// Personal and workspace subscriptions remain separate buckets.
|
|
100
100
|
export function checkAccountIdCollision(
|
|
101
101
|
chatgptAccountId: string,
|
|
102
102
|
email?: string | null,
|
|
103
103
|
plan?: unknown,
|
|
104
104
|
excludeAccountId?: string | null,
|
|
105
|
+
main: CodexCollisionIdentity | null = getObservedMainCodexCollisionIdentity() ?? null,
|
|
105
106
|
): { collision: true; reason: string } | { collision: false } {
|
|
106
107
|
const candidateEmail = normalizedEmail(email);
|
|
107
108
|
const candidateWorkspace = isWorkspacePlan(plan);
|
|
109
|
+
|
|
110
|
+
if (excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && main
|
|
111
|
+
&& sameCodexCollisionIdentity({ accountId: chatgptAccountId, email, plan }, main)) {
|
|
112
|
+
return { collision: true, reason: "Account is already configured as the native Codex main account." };
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
for (const account of loadConfig().codexAccounts ?? []) {
|
|
109
116
|
if (excludeAccountId && account.id === excludeAccountId) continue;
|
|
110
117
|
if (!isSelectableCodexPoolAccount(account)) continue;
|
|
@@ -117,3 +124,23 @@ export function checkAccountIdCollision(
|
|
|
117
124
|
}
|
|
118
125
|
return { collision: false };
|
|
119
126
|
}
|
|
127
|
+
|
|
128
|
+
export type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
129
|
+
|
|
130
|
+
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
131
|
+
export function readMainAuth(authPath = join(resolveCodexHomeDir(), "auth.json")): MainAuth {
|
|
132
|
+
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
133
|
+
if (main.status !== "invalid") return main;
|
|
134
|
+
try {
|
|
135
|
+
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
136
|
+
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
137
|
+
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
138
|
+
const apiKeyOnly = envelope.tokens == null
|
|
139
|
+
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
140
|
+
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
141
|
+
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
142
|
+
} catch {
|
|
143
|
+
return main;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
@@ -97,6 +97,7 @@ import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
|
97
97
|
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
|
|
98
98
|
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
|
|
99
99
|
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
|
|
100
|
+
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity";
|
|
100
101
|
|
|
101
102
|
/**
|
|
102
103
|
* A request-owned bearer cannot inspect the physical main credential for its plan, but cached
|
|
@@ -324,6 +325,8 @@ export type CodexAuthContext =
|
|
|
324
325
|
writerGeneration: number;
|
|
325
326
|
/** Captured before async credential work; never reconstructed after the upstream response. */
|
|
326
327
|
mainQuotaWriter?: MainQuotaWriter;
|
|
328
|
+
/** Immutable credential proof captured while materializing this physical attempt's headers. */
|
|
329
|
+
mainQuotaDispatch?: MainQuotaDispatch;
|
|
327
330
|
reserveAuthorization?: MainReserveAuthorization;
|
|
328
331
|
accessToken: string;
|
|
329
332
|
chatgptAccountId: string;
|
|
@@ -1080,6 +1083,33 @@ async function awaitMainAccountPolicyBindingSettled(
|
|
|
1080
1083
|
if (!settled) throw new CodexMainProfileDrainingError();
|
|
1081
1084
|
}
|
|
1082
1085
|
|
|
1086
|
+
/** Memory-only ownership check shared by Direct admission and pre-auth hard-lock routing. */
|
|
1087
|
+
export async function mainAccountHardLockAppliesToCaller(
|
|
1088
|
+
headers: Headers,
|
|
1089
|
+
policy: CodexAuthPolicyConfig,
|
|
1090
|
+
options: Pick<ResolveCodexAuthContextOptions,
|
|
1091
|
+
"substituteMainCredentialForDirect" | "signal" | "mainAccountPolicyBindingWaitMs"> = {},
|
|
1092
|
+
): Promise<boolean> {
|
|
1093
|
+
if (options.substituteMainCredentialForDirect === true) return true;
|
|
1094
|
+
if (!hasCallerCodexBearer(headers)) return false;
|
|
1095
|
+
if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
|
|
1096
|
+
return callerMatchesObservedMain(headers);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Resolve ownership once, then hand back a synchronous proof. Main identity or its observed bearer
|
|
1101
|
+
* can change across the await; the hard-lock reroute rechecks the proof with no await between it
|
|
1102
|
+
* and the body rewrite, so a replacement main's lock never reroutes the original caller.
|
|
1103
|
+
*/
|
|
1104
|
+
export async function mainAccountHardLockCallerProof(
|
|
1105
|
+
headers: Headers,
|
|
1106
|
+
policy: CodexAuthPolicyConfig,
|
|
1107
|
+
options: Parameters<typeof mainAccountHardLockAppliesToCaller>[2] = {},
|
|
1108
|
+
): Promise<(() => boolean) | null> {
|
|
1109
|
+
if (!await mainAccountHardLockAppliesToCaller(headers, policy, options)) return null;
|
|
1110
|
+
return options.substituteMainCredentialForDirect === true ? () => true : () => callerMatchesObservedMain(headers);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1083
1113
|
/**
|
|
1084
1114
|
* Resolve the credential for one Codex turn. A main hard-lock reached through unpinned pool
|
|
1085
1115
|
* selection is annotated with the earliest known pool recovery, so the refusal can name a retry
|
|
@@ -1172,8 +1202,7 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1172
1202
|
// Trusted substitution still has to claim and validate stored main below.
|
|
1173
1203
|
if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
1174
1204
|
if (!substituteStoredMain) {
|
|
1175
|
-
if (
|
|
1176
|
-
if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
|
|
1205
|
+
if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy);
|
|
1177
1206
|
assertCallerOwnedMainPoolNotCooled();
|
|
1178
1207
|
if (reserve) {
|
|
1179
1208
|
const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
|
|
@@ -1422,7 +1451,9 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1422
1451
|
// cool down. Hand back any trial first -- no Pool account is being sent to.
|
|
1423
1452
|
if (selected === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential) {
|
|
1424
1453
|
releaseTransientProbeGrant();
|
|
1425
|
-
|
|
1454
|
+
const context = await resolveCallerOwnedMainContext();
|
|
1455
|
+
console.info("[codex] Pool selected caller-owned main credential.");
|
|
1456
|
+
return context;
|
|
1426
1457
|
}
|
|
1427
1458
|
if (!selected) {
|
|
1428
1459
|
// A retry that excluded a failed Pool account may still use the validated caller-owned
|
|
@@ -1432,9 +1463,12 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1432
1463
|
if (
|
|
1433
1464
|
requestScopedMainCredential
|
|
1434
1465
|
&& fixedAccountId === undefined
|
|
1466
|
+
&& !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
|
|
1435
1467
|
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
|
|
1436
1468
|
) {
|
|
1437
|
-
|
|
1469
|
+
const context = await resolveCallerOwnedMainContext();
|
|
1470
|
+
console.info("[codex] Pool used caller-owned main credential: no selectable stored account.");
|
|
1471
|
+
return context;
|
|
1438
1472
|
}
|
|
1439
1473
|
if (fixedAccountId !== undefined) {
|
|
1440
1474
|
if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) {
|
|
@@ -1533,6 +1567,7 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1533
1567
|
// deferred credential must never become request auth through that fallback.
|
|
1534
1568
|
try {
|
|
1535
1569
|
assertCodexAccountValidationReady(accountId);
|
|
1570
|
+
if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
|
|
1536
1571
|
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
|
|
1537
1572
|
} catch (cause) {
|
|
1538
1573
|
// Nothing will reach upstream, so give the trial back instead of leaving the held account
|
|
@@ -1590,9 +1625,12 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1590
1625
|
// alternate is eligible. A validated caller may still serve this request,
|
|
1591
1626
|
// just as it can after an upstream rejection, without changing Pool state.
|
|
1592
1627
|
if (requestScopedMainCredential && fixedAccountId === undefined
|
|
1628
|
+
&& !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
|
|
1593
1629
|
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
|
|
1594
1630
|
&& !callerIsCooledPoolAccount(headers, config, accountId)) {
|
|
1595
|
-
|
|
1631
|
+
const context = await resolveCallerOwnedMainContext();
|
|
1632
|
+
console.info("[codex] Pool used caller-owned main credential: stored account cooldown.");
|
|
1633
|
+
return context;
|
|
1596
1634
|
}
|
|
1597
1635
|
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
|
|
1598
1636
|
}
|
|
@@ -1660,6 +1698,7 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1660
1698
|
try {
|
|
1661
1699
|
const token = await getValidCodexToken(accountId, { signal: options.signal });
|
|
1662
1700
|
assertCodexAccountValidationReady(accountId);
|
|
1701
|
+
if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
|
|
1663
1702
|
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
|
|
1664
1703
|
return bindPoolCreditPolicy({
|
|
1665
1704
|
kind: "pool",
|
|
@@ -1681,7 +1720,8 @@ async function resolveCodexAuthContextUnannotated(
|
|
|
1681
1720
|
releaseTransientProbeGrant();
|
|
1682
1721
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1683
1722
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1684
|
-
|
|
1723
|
+
// Post-refresh eligibility refusals do not invalidate a healthy credential.
|
|
1724
|
+
if (cause instanceof CodexPoolAccountCreditsOffError || cause instanceof CodexPoolAuthenticationError) throw cause;
|
|
1685
1725
|
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
1686
1726
|
markAccountNeedsReauth(accountId, writerGeneration);
|
|
1687
1727
|
}
|
|
@@ -1766,6 +1806,8 @@ export function materializeCodexUpstreamAuth(
|
|
|
1766
1806
|
if (ctx.kind === "main-pool") {
|
|
1767
1807
|
ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter);
|
|
1768
1808
|
assertMainAccountPolicy(options.config);
|
|
1809
|
+
ctx.mainQuotaDispatch = ctx.mainQuotaWriter && isMainQuotaWriterLive(ctx.mainQuotaWriter)
|
|
1810
|
+
? selectedMainQuotaDispatch(selected) : undefined;
|
|
1769
1811
|
}
|
|
1770
1812
|
assertMaterializedReserve(selected, ctx, options);
|
|
1771
1813
|
return selected;
|
|
@@ -213,9 +213,15 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget
|
|
|
213
213
|
* reverse: they are output paths, so ambient values would write Node artifacts to
|
|
214
214
|
* arbitrary external directories on exit. ELF/Darwin loader controls (LD_* and
|
|
215
215
|
* DYLD_*) are also removed: they can load code or redirect loader output before
|
|
216
|
-
* npm runs.
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
* npm runs. OpenSSL's startup inputs are removed with them: OPENSSL_CONF names
|
|
217
|
+
* a configuration file the crypto library reads at startup, OPENSSL_CONF_INCLUDE
|
|
218
|
+
* resolves relative includes inside that configuration, and OPENSSL_MODULES /
|
|
219
|
+
* OPENSSL_ENGINES name the directories its provider and engine search loads
|
|
220
|
+
* shared modules from. None of these is input the updater child needs, so
|
|
221
|
+
* ambient values stay out of the spawned npm. This is a denylist for
|
|
222
|
+
* process-startup/output channels, not a claim that every ambient variable is
|
|
223
|
+
* allowlisted. Ordinary OS environment, proxy settings and the supported
|
|
224
|
+
* NODE_EXTRA_CA_CERTS extension remain available.
|
|
219
225
|
*/
|
|
220
226
|
const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
|
|
221
227
|
"node_options",
|
|
@@ -227,6 +233,15 @@ const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
|
|
|
227
233
|
// escaping the owned temp root regardless of the isolated --cache.
|
|
228
234
|
"node_v8_coverage",
|
|
229
235
|
"node_redirect_warnings",
|
|
236
|
+
// OpenSSL startup inputs: OPENSSL_CONF points the library at an ambient
|
|
237
|
+
// startup configuration file (OPENSSL_CONF_INCLUDE resolves that config's
|
|
238
|
+
// relative includes); OPENSSL_MODULES and OPENSSL_ENGINES point its
|
|
239
|
+
// provider/engine search at ambient directories that supply loadable
|
|
240
|
+
// shared modules.
|
|
241
|
+
"openssl_conf",
|
|
242
|
+
"openssl_conf_include",
|
|
243
|
+
"openssl_modules",
|
|
244
|
+
"openssl_engines",
|
|
230
245
|
]);
|
|
231
246
|
|
|
232
247
|
function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt";
|
|
2
|
+
import type { CodexAccount, OcxConfig } from "../types";
|
|
3
|
+
import { getCodexAccountCredential } from "./account-store";
|
|
4
|
+
import type { CodexTokens } from "./auth-collision";
|
|
5
|
+
import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache";
|
|
6
|
+
import { codexPlanKey, extractChatgptPlanType } from "./plan";
|
|
7
|
+
|
|
8
|
+
export interface CodexCollisionIdentity {
|
|
9
|
+
accountId: string;
|
|
10
|
+
email?: string | null;
|
|
11
|
+
plan?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizedEmail(email: string | null | undefined): string | undefined {
|
|
15
|
+
return email?.trim().toLowerCase() || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function workspacePlan(plan: unknown): boolean {
|
|
19
|
+
return /team|business|enterprise|workspace|edu/.test(codexPlanKey(plan) ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Missing member evidence cannot establish a distinct subscription within one plan bucket. */
|
|
23
|
+
export function sameCodexCollisionIdentity(a: CodexCollisionIdentity, b: CodexCollisionIdentity): boolean {
|
|
24
|
+
if (!a.accountId || a.accountId !== b.accountId || workspacePlan(a.plan) !== workspacePlan(b.plan)) return false;
|
|
25
|
+
const aEmail = normalizedEmail(a.email);
|
|
26
|
+
const bEmail = normalizedEmail(b.email);
|
|
27
|
+
return !aEmail || !bEmail || aEmail === bEmail;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Ambient scope comparison is memory-only, including when the default-home resolver would probe WSL.
|
|
31
|
+
function homeScope(): string {
|
|
32
|
+
return JSON.stringify([process.env.CODEX_HOME, process.env.HOME, process.env.USERPROFILE, process.env.OPENCODEX_HOME]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined;
|
|
36
|
+
|
|
37
|
+
/** Only existing owned lifecycle observations may publish; never call with a request bearer. */
|
|
38
|
+
export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void {
|
|
39
|
+
if (!tokens) { observed = undefined; return; }
|
|
40
|
+
if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
|
|
41
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return;
|
|
42
|
+
const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
|
|
43
|
+
if (claims.some(claim => claim.conflict)) return;
|
|
44
|
+
const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId;
|
|
45
|
+
if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return;
|
|
46
|
+
const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
|
|
47
|
+
.flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : []));
|
|
48
|
+
if (emails.size > 1) return;
|
|
49
|
+
observed = { identity: { accountId, email: [...emails][0],
|
|
50
|
+
plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) },
|
|
51
|
+
homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Compare known identity only. External logins become visible at the next owned observation. */
|
|
55
|
+
export function getObservedMainCodexCollisionIdentity(): Readonly<CodexCollisionIdentity> | undefined {
|
|
56
|
+
return observed?.homeScope === homeScope() && isMainAccountIdentityGenerationLive(observed.generation)
|
|
57
|
+
? observed.identity : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isStoredCodexMainDuplicate(account: CodexAccount): boolean {
|
|
61
|
+
const main = getObservedMainCodexCollisionIdentity();
|
|
62
|
+
if (account.isMain || !main) return false;
|
|
63
|
+
const credential = getCodexAccountCredential(account.id);
|
|
64
|
+
if (!credential?.chatgptAccountId) return false;
|
|
65
|
+
const storedEmail = normalizedEmail(account.email);
|
|
66
|
+
const email = storedEmail && storedEmail !== normalizedEmail(account.id)
|
|
67
|
+
? storedEmail : extractEmail(undefined, credential.accessToken);
|
|
68
|
+
return sameCodexCollisionIdentity({ accountId: credential.chatgptAccountId, email,
|
|
69
|
+
plan: codexPlanKey(account.plan) ? account.plan : extractChatgptPlanType(undefined, credential.accessToken) }, main);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const CODEX_MAIN_DUPLICATE_ACTION =
|
|
73
|
+
"Use the native Codex main account, or sign native Codex into a different account to use this stored Pool entry.";
|
|
74
|
+
|
|
75
|
+
export function configuredCodexMainDuplicate(config: Pick<OcxConfig, "codexAccounts">, accountId: string): boolean {
|
|
76
|
+
const account = config.codexAccounts?.find(row => row.id === accountId);
|
|
77
|
+
return !!account && isStoredCodexMainDuplicate(account);
|
|
78
|
+
}
|
|
@@ -121,3 +121,17 @@ export function getMainAccountHardLockStatus(
|
|
|
121
121
|
export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
|
|
122
122
|
return getMainAccountHardLockStatus(config, now).state === "blocked";
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Scheduling evidence only: the latest blocking reset even after it has passed, so recovery can
|
|
127
|
+
* notice a revised reset that expired between sweeps. Never an admission or unlock signal.
|
|
128
|
+
*/
|
|
129
|
+
export function latestMainBlockingResetAt(config: PolicyConfig): number | undefined {
|
|
130
|
+
if (!isMainAccountHardLockEnabled(config)) return undefined;
|
|
131
|
+
const quota = getMainPolicyQuota();
|
|
132
|
+
if (!quota) return undefined;
|
|
133
|
+
const thresholds = resolveMainAccountHardLockThresholds(config);
|
|
134
|
+
const resets = governingWindows(quota)
|
|
135
|
+
.filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]).map(w => resetTimestamp(w.resetAt));
|
|
136
|
+
return resets.length > 0 && resets.every(r => r !== undefined) ? Math.max(...(resets as number[])) : undefined;
|
|
137
|
+
}
|
|
@@ -6,6 +6,7 @@ import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
|
6
6
|
import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
|
|
7
7
|
import type { CodexQuotaScope } from "./health-store";
|
|
8
8
|
import type { TransientProbeGrant } from "./thread-affinity";
|
|
9
|
+
import type { MainQuotaDispatch } from "../main-account-cache";
|
|
9
10
|
|
|
10
11
|
export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
|
|
11
12
|
|
|
@@ -99,6 +100,10 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
99
100
|
* means the caller cannot supply lineage and the historical unfenced handling stands.
|
|
100
101
|
*/
|
|
101
102
|
credentialGeneration?: number;
|
|
103
|
+
/** Main credential proof captured before dispatch; null means no owned proof was available. */
|
|
104
|
+
mainFailureWindowDispatch?: MainQuotaDispatch | null;
|
|
105
|
+
/** Pool sample provenance retained by a terminal callback even if its context is reused. */
|
|
106
|
+
failureWindowCredentialGeneration?: number;
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
export function computeCodexUsageScore(quota: {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { pinnedCodexAccountId } from "../account-priority";
|
|
2
2
|
import type { OcxConfig } from "../../types";
|
|
3
|
+
import { beginCodexAccountGenerationLiveCheck, isCodexAccountGenerationLive } from "../account-store";
|
|
4
|
+
import { isMainQuotaDispatchLive } from "../main-account-cache";
|
|
5
|
+
import type { CodexAuthContext } from "../auth-context";
|
|
6
|
+
import type { CodexUpstreamOutcomeMeta } from "./cooldown-math";
|
|
3
7
|
import {
|
|
4
8
|
deleteAccountHealth,
|
|
5
9
|
setAccountHealth,
|
|
@@ -14,12 +18,31 @@ export const CODEX_SLIDING_FAILURE_RECOVER_RATIO = 0.1;
|
|
|
14
18
|
export const CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS = 30_000;
|
|
15
19
|
const MAX_SAMPLES = 512;
|
|
16
20
|
|
|
17
|
-
type Sample = { at: number; failed: boolean };
|
|
18
|
-
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number };
|
|
21
|
+
type Sample = { at: number; failed: boolean; credentialGeneration?: number };
|
|
22
|
+
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number; credentialGeneration?: number };
|
|
19
23
|
|
|
20
24
|
const windows = new Map<string, WindowState>();
|
|
21
25
|
const pinnedDegradeWarned = new Set<string>();
|
|
22
26
|
|
|
27
|
+
/** Read only dispatch-time provenance retained by auth materialization, never recapture at arrival. */
|
|
28
|
+
export function codexFailureWindowOutcomeMeta(
|
|
29
|
+
context: CodexAuthContext,
|
|
30
|
+
): Pick<CodexUpstreamOutcomeMeta, "failureWindowCredentialGeneration" | "mainFailureWindowDispatch"> {
|
|
31
|
+
if (context.kind === "pool") return { failureWindowCredentialGeneration: context.generation };
|
|
32
|
+
return context.kind === "main-pool" ? { mainFailureWindowDispatch: context.mainQuotaDispatch ?? null } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fence window samples only; other health and recovery-lease semantics stay with their owners. */
|
|
36
|
+
export function noteCodexFailureWindowOutcome(
|
|
37
|
+
config: OcxConfig, accountId: string, failed: boolean, now: number, meta: CodexUpstreamOutcomeMeta,
|
|
38
|
+
): void {
|
|
39
|
+
if (meta.mainFailureWindowDispatch !== undefined
|
|
40
|
+
&& (!meta.mainFailureWindowDispatch || !isMainQuotaDispatchLive(meta.mainFailureWindowDispatch))) return;
|
|
41
|
+
const generation = meta.failureWindowCredentialGeneration ?? meta.credentialGeneration;
|
|
42
|
+
if (generation !== undefined && !isCodexAccountGenerationLive(accountId, generation)) return;
|
|
43
|
+
appendSample(config, accountId, failed, now, generation);
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
export function codexFailureWindowEnabled(config: OcxConfig): boolean {
|
|
24
47
|
return config.codexFailureWindow !== false;
|
|
25
48
|
}
|
|
@@ -33,11 +56,27 @@ function stateFor(accountId: string): WindowState {
|
|
|
33
56
|
return state;
|
|
34
57
|
}
|
|
35
58
|
|
|
36
|
-
function recompute(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
function recompute(
|
|
60
|
+
accountId: string, state: WindowState, now: number,
|
|
61
|
+
generationLive?: ReturnType<typeof beginCodexAccountGenerationLiveCheck>,
|
|
62
|
+
): void {
|
|
63
|
+
// One store snapshot per insertion/read, never one file load per retained sample. The
|
|
64
|
+
// unlocked insertion precheck can race another process; provenance must survive it.
|
|
65
|
+
const tagged = state.credentialGeneration !== undefined
|
|
66
|
+
|| state.samples.some(sample => sample.credentialGeneration !== undefined);
|
|
67
|
+
const live = generationLive ?? (tagged ? beginCodexAccountGenerationLiveCheck() : undefined);
|
|
68
|
+
// Unknown evidence is not a credential removal: preserve samples and all derived state.
|
|
69
|
+
if (live?.snapshotStatus === "failed") return;
|
|
70
|
+
if (state.credentialGeneration !== undefined && !live?.(accountId, state.credentialGeneration)) {
|
|
71
|
+
state.degraded = false;
|
|
72
|
+
state.healthySince = undefined;
|
|
73
|
+
state.credentialGeneration = undefined;
|
|
74
|
+
pinnedDegradeWarned.delete(accountId);
|
|
40
75
|
}
|
|
76
|
+
const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
|
|
77
|
+
state.samples = state.samples.filter(sample => sample.at > cutoff
|
|
78
|
+
&& (sample.credentialGeneration === undefined || live?.(accountId, sample.credentialGeneration)));
|
|
79
|
+
state.credentialGeneration ??= state.samples.find(sample => sample.credentialGeneration !== undefined)?.credentialGeneration;
|
|
41
80
|
const samples = state.samples.length;
|
|
42
81
|
const failed = state.samples.reduce((count, sample) => count + (sample.failed ? 1 : 0), 0);
|
|
43
82
|
const degraded = samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
|
|
@@ -71,19 +110,32 @@ export function noteCodexFailureWindowSample(
|
|
|
71
110
|
accountId: string,
|
|
72
111
|
failed: boolean,
|
|
73
112
|
now: number,
|
|
113
|
+
): void {
|
|
114
|
+
appendSample(config, accountId, failed, now);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function appendSample(
|
|
118
|
+
config: OcxConfig, accountId: string, failed: boolean, now: number, credentialGeneration?: number,
|
|
74
119
|
): void {
|
|
75
120
|
if (!codexFailureWindowEnabled(config)) return;
|
|
121
|
+
const existing = windows.get(accountId);
|
|
122
|
+
const tagged = credentialGeneration !== undefined || existing?.credentialGeneration !== undefined
|
|
123
|
+
|| existing?.samples.some(sample => sample.credentialGeneration !== undefined);
|
|
124
|
+
const live = tagged ? beginCodexAccountGenerationLiveCheck() : undefined;
|
|
125
|
+
if (live?.snapshotStatus === "failed") return;
|
|
126
|
+
// A stale arrival is not an ownership transition and must not delete a live replacement's state.
|
|
127
|
+
if (credentialGeneration !== undefined && !live?.(accountId, credentialGeneration)) return;
|
|
76
128
|
const state = stateFor(accountId);
|
|
77
|
-
state.samples.push({ at: now, failed });
|
|
129
|
+
state.samples.push({ at: now, failed, ...(credentialGeneration === undefined ? {} : { credentialGeneration }) });
|
|
78
130
|
if (state.samples.length > MAX_SAMPLES) state.samples.splice(0, state.samples.length - MAX_SAMPLES);
|
|
79
|
-
recompute(state, now);
|
|
131
|
+
recompute(accountId, state, now, live);
|
|
80
132
|
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
|
|
81
133
|
}
|
|
82
134
|
|
|
83
135
|
export function isCodexFailureWindowDegraded(accountId: string, now = Date.now()): boolean {
|
|
84
136
|
const state = windows.get(accountId);
|
|
85
137
|
if (!state) return false;
|
|
86
|
-
recompute(state, now);
|
|
138
|
+
recompute(accountId, state, now);
|
|
87
139
|
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
|
|
88
140
|
return state.degraded;
|
|
89
141
|
}
|