@bitkyc08/opencodex 2.88.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/README.md +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- 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/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- 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/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- 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/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- 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/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -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/main-account-probe.ts +3 -2
- 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 +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- 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 +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -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/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.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/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- 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/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- 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-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- 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 +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
|
@@ -9,6 +9,7 @@ import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./a
|
|
|
9
9
|
import type { OcxConfig } from "../types";
|
|
10
10
|
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
|
|
11
11
|
import { isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
12
|
+
import { configuredCodexMainDuplicate } from "./collision-identity";
|
|
12
13
|
|
|
13
14
|
export interface CodexAccountUsabilityOptions {
|
|
14
15
|
/** Route using cached runtime state only; the caller must reject selected main before auth. */
|
|
@@ -64,7 +65,8 @@ export type CodexAccountUnusableReason =
|
|
|
64
65
|
| "not_in_pool"
|
|
65
66
|
| "missing_credential"
|
|
66
67
|
| "deleted"
|
|
67
|
-
| "validation_pending"
|
|
68
|
+
| "validation_pending"
|
|
69
|
+
| "duplicate_main";
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
|
|
@@ -110,6 +112,7 @@ export function codexAccountUnusableReason(
|
|
|
110
112
|
if (!record?.credential) return "missing_credential";
|
|
111
113
|
if (record.deletedAt != null) return "deleted";
|
|
112
114
|
if (record.codexValidationPending) return "validation_pending";
|
|
115
|
+
if (configuredCodexMainDuplicate(config, accountId)) return "duplicate_main";
|
|
113
116
|
return undefined;
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
|
|
2
2
|
import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
|
|
3
3
|
import type { CodexCredits } from "../credits";
|
|
4
|
-
import { getMainChatgptAccountId } from "../auth-collision";
|
|
5
4
|
import { codexAccountLogLabel } from "../account-label";
|
|
6
5
|
import { poolQuotaHistoryIdentity, getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
|
|
7
6
|
import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
|
|
@@ -35,6 +34,7 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC
|
|
|
35
34
|
import type { PoolQuotaResult } from "./pool-quota-probe";
|
|
36
35
|
import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
|
|
37
36
|
import { isPassiveRead } from "../../lib/passive-read";
|
|
37
|
+
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity";
|
|
38
38
|
|
|
39
39
|
export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
40
40
|
quota: T,
|
|
@@ -133,6 +133,7 @@ export function poolAccountDto(
|
|
|
133
133
|
// health projection. Emitting only the boolean is what left #4212's reporter guessing which
|
|
134
134
|
// account took their model away and why, so name the cause they actually have to act on.
|
|
135
135
|
const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
|
|
136
|
+
const duplicateMain = configuredCodexMainDuplicate(config, account.id);
|
|
136
137
|
return {
|
|
137
138
|
id: account.id,
|
|
138
139
|
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
@@ -141,6 +142,7 @@ export function poolAccountDto(
|
|
|
141
142
|
logLabel: codexAccountLogLabel(account),
|
|
142
143
|
isMain: false,
|
|
143
144
|
paused,
|
|
145
|
+
...(config.codexAccountPauseState?.[account.id]?.details ? { pauseDetails: config.codexAccountPauseState[account.id]!.details } : {}),
|
|
144
146
|
priority,
|
|
145
147
|
autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id),
|
|
146
148
|
creditsAfterLimit: codexAccountUsesCreditsAfterLimit(config, account.id),
|
|
@@ -149,7 +151,10 @@ export function poolAccountDto(
|
|
|
149
151
|
...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
|
|
150
152
|
needsReauth: needsReauth || health.status === "reauth_required",
|
|
151
153
|
...(reauthReason !== undefined ? { reauthReason } : {}),
|
|
152
|
-
...(
|
|
154
|
+
...(duplicateMain ? {
|
|
155
|
+
selectionExcludedReason: "duplicate_main" as const,
|
|
156
|
+
selectionExcludedAction: CODEX_MAIN_DUPLICATE_ACTION,
|
|
157
|
+
} : isCodexAccountPlanExcluded(config, account.id) ? {
|
|
153
158
|
selectionExcludedReason: "plan_excluded" as const,
|
|
154
159
|
selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
|
|
155
160
|
} : {}),
|
|
@@ -167,6 +172,7 @@ export interface CodexAuthAccountDto {
|
|
|
167
172
|
logLabel?: string;
|
|
168
173
|
isMain: boolean;
|
|
169
174
|
paused: boolean;
|
|
175
|
+
pauseDetails?: import("../../types/account-auto-pause").AccountPauseDetails;
|
|
170
176
|
/** Selection order; higher is used earlier. Always present, 0 when unset. */
|
|
171
177
|
priority: number;
|
|
172
178
|
/** Null inherits the global usage-switch threshold; 0 disables it for this account. */
|
|
@@ -182,8 +188,9 @@ export interface CodexAuthAccountDto {
|
|
|
182
188
|
* needs the operator; `/api/oauth/accounts` already carries the same field name.
|
|
183
189
|
*/
|
|
184
190
|
reauthReason?: CodexAccountReauthReason;
|
|
185
|
-
/**
|
|
186
|
-
selectionExcludedReason?: "plan_excluded";
|
|
191
|
+
/** Plan policy is automatic-only; duplicate-main exclusions also apply to explicit routes. */
|
|
192
|
+
selectionExcludedReason?: "plan_excluded" | "duplicate_main";
|
|
193
|
+
selectionExcludedAction?: string;
|
|
187
194
|
selectionExcludedPlan?: string;
|
|
188
195
|
hasCredential: boolean;
|
|
189
196
|
health: OAuthAccountHealth;
|
|
@@ -403,6 +410,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
403
410
|
logLabel: "main",
|
|
404
411
|
isMain: true,
|
|
405
412
|
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
413
|
+
...(runtimeConfig.codexAccountPauseState?.[MAIN_CODEX_ACCOUNT_ID]?.details ? { pauseDetails: runtimeConfig.codexAccountPauseState[MAIN_CODEX_ACCOUNT_ID]!.details } : {}),
|
|
406
414
|
mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
|
|
407
415
|
externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
|
|
408
416
|
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
@@ -415,7 +423,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
415
423
|
quota: mainInfo.quota
|
|
416
424
|
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
|
|
417
425
|
: null,
|
|
418
|
-
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID,
|
|
426
|
+
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getObservedMainCodexCollisionIdentity()?.accountId ?? null),
|
|
419
427
|
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
420
428
|
};
|
|
421
429
|
return {
|
|
@@ -1,35 +1,15 @@
|
|
|
1
1
|
import { observeSignedInCodexIdentity } from "../signed-in-identity";
|
|
2
2
|
import { lstatSync } from "node:fs";
|
|
3
3
|
import type { OcxConfig } from "../../types";
|
|
4
|
-
import {
|
|
5
|
-
import type { CodexTokenReadResult } from "../auth-collision";
|
|
4
|
+
import { readMainAuth } from "../auth-collision";
|
|
6
5
|
import { linkedCodexAccountIds } from "../account-identity";
|
|
7
6
|
import { resolveCodexHomeDir } from "../home";
|
|
8
7
|
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
9
8
|
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
10
|
-
import {
|
|
9
|
+
import { resolveNativeProfileContext } from "../native-profile-store";
|
|
11
10
|
import { NativeProfileError } from "../native-profile-types";
|
|
12
11
|
import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
|
|
13
12
|
|
|
14
|
-
type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
15
|
-
|
|
16
|
-
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
17
|
-
function readMainAuth(authPath: string): MainAuth {
|
|
18
|
-
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
19
|
-
if (main.status !== "invalid") return main;
|
|
20
|
-
try {
|
|
21
|
-
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
22
|
-
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
23
|
-
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
24
|
-
const apiKeyOnly = envelope.tokens == null
|
|
25
|
-
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
26
|
-
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
27
|
-
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
28
|
-
} catch {
|
|
29
|
-
return main;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
13
|
/** Hold admission throughout; claim the physical main whenever its home exists. */
|
|
34
14
|
export async function withCodexAccountPauseGroup(
|
|
35
15
|
config: OcxConfig,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { extractAccountId, extractAccountIdClaims, extractEmail } from "../../oauth/chatgpt";
|
|
3
|
+
import { checkAccountIdCollision, readMainAuth } from "../auth-collision";
|
|
4
|
+
import { resolveCodexHomeDir } from "../home";
|
|
5
|
+
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
6
|
+
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
7
|
+
import { resolveNativeProfileContext } from "../native-profile-store";
|
|
8
|
+
import { NativeProfileError } from "../native-profile-types";
|
|
9
|
+
import { extractChatgptPlanType } from "../plan";
|
|
10
|
+
import { isNativeMainClaimUnavailable } from "./http";
|
|
11
|
+
|
|
12
|
+
export class CodexCollisionReadBusyError extends Error {
|
|
13
|
+
constructor() { super("Native Codex login is busy or unreadable; retry login shortly."); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Only admission owns physical reads; synchronous collision consumers use observed identity. */
|
|
17
|
+
export async function checkAccountIdCollisionWithNativeMain(
|
|
18
|
+
accountId: string, email?: string | null, plan?: unknown, excludeAccountId?: string | null,
|
|
19
|
+
): Promise<ReturnType<typeof checkAccountIdCollision>> {
|
|
20
|
+
const lease = tryAcquireNativeMainProfileClaim();
|
|
21
|
+
if (!lease) throw new CodexCollisionReadBusyError();
|
|
22
|
+
try {
|
|
23
|
+
const codexHome = resolveCodexHomeDir();
|
|
24
|
+
try { lstatSync(codexHome); } catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
26
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
|
|
27
|
+
}
|
|
28
|
+
throw new CodexCollisionReadBusyError();
|
|
29
|
+
}
|
|
30
|
+
const context = resolveNativeProfileContext({ codexHome });
|
|
31
|
+
return await withNativeMainSharedClaim(context, async () => {
|
|
32
|
+
const main = readMainAuth(context.authPath);
|
|
33
|
+
if (main.status === "missing" || main.status === "api-key-only") {
|
|
34
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
|
|
35
|
+
}
|
|
36
|
+
if (main.status !== "ok") throw new CodexCollisionReadBusyError();
|
|
37
|
+
const tokens = main.tokens;
|
|
38
|
+
const nativeId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
|
|
39
|
+
const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
|
|
40
|
+
const emails = [extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)].filter(Boolean);
|
|
41
|
+
if (!nativeId || typeof tokens.account_id !== "string" || typeof tokens.access_token !== "string"
|
|
42
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")
|
|
43
|
+
|| claims.some(claim => claim.conflict || (claim.accountId && claim.accountId !== nativeId))
|
|
44
|
+
|| (tokens.account_id && tokens.account_id !== nativeId) || new Set(emails).size > 1) {
|
|
45
|
+
throw new CodexCollisionReadBusyError();
|
|
46
|
+
}
|
|
47
|
+
return checkAccountIdCollision(accountId, email, plan, excludeAccountId, {
|
|
48
|
+
accountId: nativeId, email: extractEmail(tokens.id_token, tokens.access_token),
|
|
49
|
+
plan: extractChatgptPlanType(tokens.id_token, tokens.access_token),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (isNativeMainClaimUnavailable(error)
|
|
54
|
+
|| (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
|
|
55
|
+
throw new CodexCollisionReadBusyError();
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
} finally { lease.release(); }
|
|
59
|
+
}
|
|
@@ -5,7 +5,8 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
|
|
|
5
5
|
import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
|
|
6
6
|
import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
|
|
7
7
|
import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status";
|
|
8
|
-
import { checkAccountIdCollision } from "
|
|
8
|
+
import { checkAccountIdCollisionWithNativeMain as checkAccountIdCollision, CodexCollisionReadBusyError } from "./login-collision";
|
|
9
|
+
import { extractChatgptPlanType } from "../plan";
|
|
9
10
|
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
|
|
10
11
|
import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
|
|
11
12
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
@@ -262,8 +263,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
262
263
|
break;
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
let email = cred.email ||
|
|
266
|
-
let plan
|
|
266
|
+
let email = cred.email?.trim() || "";
|
|
267
|
+
let plan = extractChatgptPlanType(undefined, cred.access);
|
|
267
268
|
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
268
269
|
let policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
269
270
|
try {
|
|
@@ -274,8 +275,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
274
275
|
});
|
|
275
276
|
if (resp.ok) {
|
|
276
277
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
277
|
-
email = data.email
|
|
278
|
-
plan = nonEmptyPlan(data.plan_type) ??
|
|
278
|
+
email = data.email?.trim() || email;
|
|
279
|
+
plan = nonEmptyPlan(data.plan_type) ?? plan;
|
|
279
280
|
quota = parseUsageQuota(data);
|
|
280
281
|
policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
|
|
281
282
|
}
|
|
@@ -322,7 +323,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
322
323
|
}
|
|
323
324
|
|
|
324
325
|
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
325
|
-
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
326
|
+
const collision = await checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
326
327
|
if (collision.collision) {
|
|
327
328
|
setCodexLoginState(flowId, {
|
|
328
329
|
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
@@ -331,6 +332,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
331
332
|
break;
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
email ||= accountId;
|
|
336
|
+
|
|
334
337
|
// A successful authenticated WHAM read can prove quota is exhausted without
|
|
335
338
|
// spending an inference request. Store the account, but defer inference validation
|
|
336
339
|
// and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
|
|
@@ -470,7 +473,9 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
470
473
|
});
|
|
471
474
|
}
|
|
472
475
|
} catch (error) {
|
|
473
|
-
const message = error instanceof
|
|
476
|
+
const message = error instanceof CodexCollisionReadBusyError
|
|
477
|
+
? "Native Codex login is busy or unreadable; retry login shortly."
|
|
478
|
+
: error instanceof ConfigMutationLockError
|
|
474
479
|
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
475
480
|
? "Configuration is busy; retry login shortly."
|
|
476
481
|
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|
|
@@ -6,7 +6,7 @@ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../.
|
|
|
6
6
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
7
7
|
import { isSelectableCodexPoolAccount } from "../account-id";
|
|
8
8
|
import type { OcxConfig } from "../../types";
|
|
9
|
-
import { parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
|
|
9
|
+
import { isValidWhamHistoryObservation, parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
|
|
10
10
|
import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
|
|
11
11
|
import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
|
|
12
12
|
import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision";
|
|
@@ -278,6 +278,7 @@ export async function fetchMainAccountInfoWhileOwned(
|
|
|
278
278
|
let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
|
|
279
279
|
let quotaRefreshGeneration = captureMainAccountIdentityGeneration();
|
|
280
280
|
const readState: { owner?: CodexUsageOwner<MainAccountInfoFetchResult>; usable: boolean } = { usable: false };
|
|
281
|
+
/** Perform one single-flight main-account usage read: classify auth failures and publish parsed quota and decision evidence only while the dispatch and credential are still current. */
|
|
281
282
|
const read = async (): Promise<MainAccountInfoFetchResult> => {
|
|
282
283
|
try {
|
|
283
284
|
let dispatchSequence = 0;
|
|
@@ -398,7 +399,7 @@ export async function fetchMainAccountInfoWhileOwned(
|
|
|
398
399
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
399
400
|
setMainAccountPlan(result.plan);
|
|
400
401
|
if (result.quota) {
|
|
401
|
-
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
|
|
402
|
+
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota, undefined, false, false, isValidWhamHistoryObservation(usage) ? quota : null);
|
|
402
403
|
}
|
|
403
404
|
publishQuotaDispatch(dispatchSequence);
|
|
404
405
|
readState.usable = quota !== null;
|
|
@@ -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
|
+
|