@bitkyc08/opencodex 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
|
|
2
|
+
import type { PoolQuotaWriter } from "../quota-types";
|
|
3
|
+
import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
|
|
4
|
+
import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
|
|
5
|
+
import type { ManualResetRefreshLineage } from "../routing";
|
|
6
|
+
import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
|
|
7
|
+
import { captureConfigGeneration } from "../../lib/state-store-sweeper";
|
|
8
|
+
import { codexWarmupFailureReason, warmCodexAccount } from "../warmup";
|
|
9
|
+
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
10
|
+
import { ResourceAdmissionError } from "../../lib/admission";
|
|
11
|
+
import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
|
|
12
|
+
import { claimQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
|
|
13
|
+
import { seedLoginRowsForTests } from "./login-state";
|
|
14
|
+
import { nonEmptyPlan } from "./runtime-config";
|
|
15
|
+
|
|
16
|
+
export const POOL_CACHE_TTL = 5 * 60_000;
|
|
17
|
+
export const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
|
|
18
|
+
|
|
19
|
+
export const MAIN_TERMINAL_AUTH_CODES = new Set([
|
|
20
|
+
"invalid_workspace_selected",
|
|
21
|
+
"invalid_refresh_token",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
|
|
25
|
+
try {
|
|
26
|
+
const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
|
|
27
|
+
if (!body.displaySafe) return undefined;
|
|
28
|
+
const parsed = JSON.parse(body.text) as {
|
|
29
|
+
detail?: { code?: unknown } | string;
|
|
30
|
+
error?: { code?: unknown } | string;
|
|
31
|
+
code?: unknown;
|
|
32
|
+
};
|
|
33
|
+
const code = typeof parsed.detail === "object" && parsed.detail !== null
|
|
34
|
+
? parsed.detail.code
|
|
35
|
+
: typeof parsed.error === "object" && parsed.error !== null
|
|
36
|
+
? parsed.error.code
|
|
37
|
+
: parsed.code;
|
|
38
|
+
return code;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PoolQuotaResult {
|
|
45
|
+
/** Actual refresh result attached only to the successful usage replay. */
|
|
46
|
+
resetRefreshLineage?: ManualResetRefreshLineage;
|
|
47
|
+
quota: StoredAccountQuota | null;
|
|
48
|
+
needsReauth: boolean;
|
|
49
|
+
/** Credential generation whose cache or network result this DTO state belongs to. */
|
|
50
|
+
credentialGeneration?: number;
|
|
51
|
+
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
52
|
+
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
53
|
+
/** Present only when this call's WHAM response included a non-empty `plan_type`. */
|
|
54
|
+
freshPlan?: string;
|
|
55
|
+
/** Credential generation used by this fresh quota request. */
|
|
56
|
+
freshCredentialGeneration?: number;
|
|
57
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
58
|
+
freshResetCredits?: number;
|
|
59
|
+
quotaProbeSkipped?: true;
|
|
60
|
+
/** Positive evidence captured immediately before an upstream WHAM dispatch. */
|
|
61
|
+
quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Process-local ordering, never a timestamp or a serialized account identifier.
|
|
65
|
+
let quotaDispatchSequence = 0;
|
|
66
|
+
// Shared native-main ownership permits concurrent usage readers. Only a later
|
|
67
|
+
// successfully published response advances this fence; failed reads do not win.
|
|
68
|
+
let mainQuotaPublishedSequence = 0;
|
|
69
|
+
|
|
70
|
+
export function nextQuotaDispatchSequence(): number {
|
|
71
|
+
return ++quotaDispatchSequence;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function currentQuotaDispatchSequence(): number {
|
|
75
|
+
return quotaDispatchSequence;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isQuotaDispatchCurrent(sequence: number): boolean {
|
|
79
|
+
return sequence >= mainQuotaPublishedSequence;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function publishQuotaDispatch(sequence: number): void {
|
|
83
|
+
mainQuotaPublishedSequence = sequence;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PoolQuotaProbeEvidence {
|
|
87
|
+
onDispatch?: (sequence: number) => void;
|
|
88
|
+
mayPublish?: () => boolean;
|
|
89
|
+
attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void {
|
|
93
|
+
const dispatchSequence = nextQuotaDispatchSequence();
|
|
94
|
+
evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence };
|
|
95
|
+
evidence.onDispatch?.(dispatchSequence);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function withQuotaProbeEvidence(
|
|
99
|
+
result: PoolQuotaResult,
|
|
100
|
+
evidence: PoolQuotaProbeEvidence,
|
|
101
|
+
): PoolQuotaResult {
|
|
102
|
+
return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface PoolQuotaRefreshFlight {
|
|
106
|
+
state: {
|
|
107
|
+
dispatchSequence?: number;
|
|
108
|
+
superseded?: boolean;
|
|
109
|
+
startCredentialGeneration?: number;
|
|
110
|
+
resolvedCredentialGeneration?: number;
|
|
111
|
+
validatePending?: boolean;
|
|
112
|
+
};
|
|
113
|
+
promise: Promise<PoolQuotaResult>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
|
|
117
|
+
export const MAX_POOL_QUOTA_FLIGHTS = 16;
|
|
118
|
+
|
|
119
|
+
export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
|
|
120
|
+
constructor() {
|
|
121
|
+
super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
|
|
122
|
+
this.name = "PoolQuotaProbeBusyError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function poolQuotaFlightCount(): number {
|
|
127
|
+
let count = 0;
|
|
128
|
+
for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
|
|
129
|
+
return count;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
|
|
133
|
+
export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
|
|
134
|
+
const prefix = `admission-test-${crypto.randomUUID()}`;
|
|
135
|
+
const cleanupLoginRows = seedLoginRowsForTests(prefix, options.loginFlows ?? 0);
|
|
136
|
+
for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
|
|
137
|
+
poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
|
|
138
|
+
state: {},
|
|
139
|
+
promise: new Promise<PoolQuotaResult>(() => {}),
|
|
140
|
+
}]));
|
|
141
|
+
}
|
|
142
|
+
return () => {
|
|
143
|
+
cleanupLoginRows();
|
|
144
|
+
for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019).
|
|
150
|
+
*
|
|
151
|
+
* The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is
|
|
152
|
+
* exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy
|
|
153
|
+
* credential was thrown away and the operator was told to log in again.
|
|
154
|
+
*
|
|
155
|
+
* Bounded by the recovery store: one attempt per credential lineage. An unbounded retry
|
|
156
|
+
* against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the
|
|
157
|
+
* claim is taken BEFORE the refresh and settled by the flight rather than by this caller.
|
|
158
|
+
*/
|
|
159
|
+
export async function recoverPoolQuotaFrom401(ctx: {
|
|
160
|
+
accountId: string;
|
|
161
|
+
existing: StoredAccountQuota | null;
|
|
162
|
+
configuredPlan: string | undefined;
|
|
163
|
+
rejectedAccessToken: string;
|
|
164
|
+
rejectedGeneration: number;
|
|
165
|
+
resp: Response;
|
|
166
|
+
quotaProbeEvidence: PoolQuotaProbeEvidence;
|
|
167
|
+
onCredentialGeneration?: (generation: number) => void;
|
|
168
|
+
}): Promise<PoolQuotaResult> {
|
|
169
|
+
const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
|
|
170
|
+
|
|
171
|
+
// Structured terminal evidence short-circuits everything: the same allowlist and bounded
|
|
172
|
+
// parser the main account uses, because it is the same endpoint answering.
|
|
173
|
+
if (await isTerminalPoolAuthResponse(resp)) {
|
|
174
|
+
// Durable, not just this response: the account list re-polls, and without a recorded
|
|
175
|
+
// mark the next bare 401 finds nothing terminal and reports the account healthy.
|
|
176
|
+
//
|
|
177
|
+
// Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive
|
|
178
|
+
// the credential it condemned, so a late terminal response arriving after the operator
|
|
179
|
+
// re-authenticated would quarantine the replacement.
|
|
180
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
181
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const claim = claimQuotaRecovery(accountId, rejectedGeneration);
|
|
185
|
+
if (!claim.granted) {
|
|
186
|
+
// A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
|
|
187
|
+
// budget being used would make the next bare 401 report a dead credential as healthy.
|
|
188
|
+
if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
|
|
189
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
190
|
+
}
|
|
191
|
+
// Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
|
|
192
|
+
// transient failure is backing off. Report transient and let the next poll try —
|
|
193
|
+
// quarantining here would undo the whole point of the budget.
|
|
194
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let refreshed: Awaited<ReturnType<typeof forceRefreshCodexPoolToken>>;
|
|
198
|
+
try {
|
|
199
|
+
refreshed = await forceRefreshCodexPoolToken(accountId, {
|
|
200
|
+
rejectedGeneration,
|
|
201
|
+
rejectedAccessToken,
|
|
202
|
+
// Settlement rides the flight, not this await: a cancelled caller would otherwise
|
|
203
|
+
// leave the claim to expire while the shared refresh commits, and the already
|
|
204
|
+
// refreshed lineage would get a second attempt.
|
|
205
|
+
onSettled: outcome => {
|
|
206
|
+
if (outcome.kind === "resolved") {
|
|
207
|
+
settleQuotaRecovery(accountId, claim.claimId, outcome);
|
|
208
|
+
} else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
|
|
209
|
+
// A revoked or expired grant does not become valid on the next poll. Releasing it
|
|
210
|
+
// into backoff would let the following bare 401 find a non-terminal record and
|
|
211
|
+
// report a dead credential as healthy.
|
|
212
|
+
settleQuotaRecoveryTerminal(accountId, claim.claimId);
|
|
213
|
+
} else {
|
|
214
|
+
releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// A refresh that failed terminally is the one case where the credential really is gone.
|
|
220
|
+
// Everything else is unknown, and unknown is not proof.
|
|
221
|
+
if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
|
|
222
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
223
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
224
|
+
}
|
|
225
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A byte-identical access token means replaying earns the same 401. Report transient
|
|
229
|
+
// rather than burning the replay; the fence already moved to the returned generation.
|
|
230
|
+
if (!refreshed.rotated) {
|
|
231
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The flight may have moved the generation while this request was in the air. Tell the
|
|
235
|
+
// coalescing layer where the credential actually is, or a late caller joins on a stale
|
|
236
|
+
// generation and opens a redundant flight.
|
|
237
|
+
ctx.onCredentialGeneration?.(refreshed.generation);
|
|
238
|
+
|
|
239
|
+
const writerGeneration = captureConfigGeneration();
|
|
240
|
+
markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
|
|
241
|
+
const poolWriter = capturePoolQuotaWriter(accountId, refreshed);
|
|
242
|
+
const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
243
|
+
headers: {
|
|
244
|
+
Authorization: `Bearer ${refreshed.accessToken}`,
|
|
245
|
+
"ChatGPT-Account-Id": refreshed.chatgptAccountId,
|
|
246
|
+
},
|
|
247
|
+
signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
|
|
248
|
+
});
|
|
249
|
+
if (!replay.ok) {
|
|
250
|
+
if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) {
|
|
251
|
+
// The refresh already settled this claim non-terminally, so the record alone would
|
|
252
|
+
// let the next poll call a dead credential healthy. The evidence is about the
|
|
253
|
+
// REFRESHED credential, which is what the replay used.
|
|
254
|
+
markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
|
|
255
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
|
|
256
|
+
}
|
|
257
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
258
|
+
}
|
|
259
|
+
const result = await commitPoolQuotaResponse(replay, {
|
|
260
|
+
accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter,
|
|
261
|
+
mayPublish: ctx.quotaProbeEvidence.mayPublish,
|
|
262
|
+
});
|
|
263
|
+
return result.freshCredentialGeneration === refreshed.generation ? {
|
|
264
|
+
...result,
|
|
265
|
+
resetRefreshLineage: {
|
|
266
|
+
fromGeneration: rejectedGeneration,
|
|
267
|
+
toGeneration: refreshed.generation,
|
|
268
|
+
provenance: refreshed.provenance,
|
|
269
|
+
},
|
|
270
|
+
} : result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Backoff after a refresh failure that proved nothing about the credential. */
|
|
274
|
+
export const QUOTA_RECOVERY_BACKOFF_MS = 60_000;
|
|
275
|
+
|
|
276
|
+
/** Same allowlist and bounded parser as the main account: it is the same endpoint. */
|
|
277
|
+
export async function isTerminalPoolAuthResponse(resp: Response): Promise<boolean> {
|
|
278
|
+
// Consume the original rather than a clone. `resp.clone()` tees the body, and the
|
|
279
|
+
// bounded parser's timeout cancels only its own reader — the unread original branch
|
|
280
|
+
// keeps buffering. Nothing needs this response afterwards, so there is nothing to tee.
|
|
281
|
+
const code = await readMainAuthErrorCode(resp);
|
|
282
|
+
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A revoked or expired grant is terminal; an unknown or transport failure is not. */
|
|
286
|
+
export function isTerminalRefreshError(error: TokenRefreshError): boolean {
|
|
287
|
+
// Read the discriminator, not the message. TokenRefreshError carries `reason`, and
|
|
288
|
+
// matching on human text would let a durable quarantine decision change the next time
|
|
289
|
+
// somebody rewords an error string.
|
|
290
|
+
return error.reason === "revoked" || error.reason === "expired";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */
|
|
294
|
+
export async function commitPoolQuotaResponse(
|
|
295
|
+
resp: Response,
|
|
296
|
+
ctx: {
|
|
297
|
+
accountId: string;
|
|
298
|
+
existing: StoredAccountQuota | null;
|
|
299
|
+
configuredPlan: string | undefined;
|
|
300
|
+
generation: number;
|
|
301
|
+
writerGeneration: number;
|
|
302
|
+
poolWriter?: PoolQuotaWriter;
|
|
303
|
+
mayPublish?: () => boolean;
|
|
304
|
+
},
|
|
305
|
+
): Promise<PoolQuotaResult> {
|
|
306
|
+
const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
|
|
307
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
308
|
+
const observedAt = Date.now();
|
|
309
|
+
if (ctx.mayPublish?.() === false) {
|
|
310
|
+
return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation };
|
|
311
|
+
}
|
|
312
|
+
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
313
|
+
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
|
|
314
|
+
const freshResetCredits = quota?.resetCredits;
|
|
315
|
+
if (!quota) {
|
|
316
|
+
return {
|
|
317
|
+
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
|
|
318
|
+
needsReauth: false,
|
|
319
|
+
credentialGeneration: generation,
|
|
320
|
+
...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
324
|
+
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
325
|
+
}
|
|
326
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota,
|
|
327
|
+
ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined);
|
|
328
|
+
return {
|
|
329
|
+
quota: getAccountQuota(accountId),
|
|
330
|
+
needsReauth: false,
|
|
331
|
+
credentialGeneration: generation,
|
|
332
|
+
freshQuota: quota,
|
|
333
|
+
freshCredentialGeneration: generation,
|
|
334
|
+
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
335
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function fetchFreshPoolAccountQuota(
|
|
340
|
+
accountId: string,
|
|
341
|
+
existing: StoredAccountQuota | null,
|
|
342
|
+
configuredPlan?: string,
|
|
343
|
+
onCredentialGeneration?: (generation: number) => void,
|
|
344
|
+
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
345
|
+
quotaProbeEvidence: PoolQuotaProbeEvidence = {},
|
|
346
|
+
): Promise<PoolQuotaResult> {
|
|
347
|
+
const writerGeneration = captureConfigGeneration();
|
|
348
|
+
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
|
|
349
|
+
try {
|
|
350
|
+
const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
|
|
351
|
+
const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation });
|
|
352
|
+
requestCredentialGeneration = generation;
|
|
353
|
+
onCredentialGeneration?.(generation);
|
|
354
|
+
markQuotaProbeAttempted(quotaProbeEvidence, generation);
|
|
355
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
356
|
+
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
357
|
+
signal: AbortSignal.timeout(8000),
|
|
358
|
+
});
|
|
359
|
+
if (!resp.ok) {
|
|
360
|
+
if (resp.status !== 401) {
|
|
361
|
+
return withQuotaProbeEvidence(
|
|
362
|
+
{ quota: existing ?? null, needsReauth: false, credentialGeneration: generation },
|
|
363
|
+
quotaProbeEvidence,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
// A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
|
|
367
|
+
// quarantining on it tells the operator to re-authenticate an account that was fine
|
|
368
|
+
// (#3019). Refresh once, replay once, and only then decide.
|
|
369
|
+
const recovered = await recoverPoolQuotaFrom401({
|
|
370
|
+
accountId,
|
|
371
|
+
existing,
|
|
372
|
+
configuredPlan,
|
|
373
|
+
rejectedAccessToken: accessToken,
|
|
374
|
+
rejectedGeneration: generation,
|
|
375
|
+
resp,
|
|
376
|
+
quotaProbeEvidence,
|
|
377
|
+
onCredentialGeneration,
|
|
378
|
+
});
|
|
379
|
+
return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
|
|
380
|
+
}
|
|
381
|
+
const committed = await commitPoolQuotaResponse(resp, {
|
|
382
|
+
accountId, existing, configuredPlan, generation, writerGeneration, poolWriter,
|
|
383
|
+
mayPublish: quotaProbeEvidence.mayPublish,
|
|
384
|
+
});
|
|
385
|
+
return withQuotaProbeEvidence(committed, quotaProbeEvidence);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
388
|
+
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
389
|
+
return withQuotaProbeEvidence({
|
|
390
|
+
quota: existing ?? null,
|
|
391
|
+
needsReauth: false,
|
|
392
|
+
credentialGeneration: requestCredentialGeneration,
|
|
393
|
+
quotaProbeSkipped: true,
|
|
394
|
+
}, quotaProbeEvidence);
|
|
395
|
+
}
|
|
396
|
+
if (e instanceof TokenRefreshError) {
|
|
397
|
+
return withQuotaProbeEvidence(
|
|
398
|
+
{ quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
|
|
399
|
+
quotaProbeEvidence,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return withQuotaProbeEvidence(
|
|
403
|
+
{ quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration },
|
|
404
|
+
quotaProbeEvidence,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function fetchPoolAccountQuota(
|
|
410
|
+
accountId: string,
|
|
411
|
+
forceRefresh = false,
|
|
412
|
+
configuredPlan?: string,
|
|
413
|
+
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
414
|
+
validatePending = false,
|
|
415
|
+
afterDispatchSequence?: number,
|
|
416
|
+
): Promise<PoolQuotaResult> {
|
|
417
|
+
const existing = getAccountQuota(accountId);
|
|
418
|
+
if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
419
|
+
return {
|
|
420
|
+
quota: existing,
|
|
421
|
+
needsReauth: false,
|
|
422
|
+
credentialGeneration: readCodexAccountRecord(accountId)?.generation,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
// A token refresh may increment the generation (and rotate the refresh token) before WHAM
|
|
426
|
+
// completes. Join a flight whose starting or resolved generation is still current, but let a
|
|
427
|
+
// replacement credential with the same pool id start its own request.
|
|
428
|
+
const record = readCodexAccountRecord(accountId);
|
|
429
|
+
const flights = poolQuotaRefreshInFlight.get(accountId);
|
|
430
|
+
const current = flights && [...flights].find(flight => {
|
|
431
|
+
const generation = flight.state.resolvedCredentialGeneration
|
|
432
|
+
?? flight.state.startCredentialGeneration;
|
|
433
|
+
return !flight.state.superseded
|
|
434
|
+
&& (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
|
|
435
|
+
&& generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
|
|
436
|
+
});
|
|
437
|
+
if (current) {
|
|
438
|
+
// A manual refresh joining a passive read must not lose its validation intent.
|
|
439
|
+
current.state.validatePending ||= validatePending;
|
|
440
|
+
return current.promise;
|
|
441
|
+
}
|
|
442
|
+
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
|
|
443
|
+
|
|
444
|
+
// A post-reset request must not let an older same-account response overwrite its evidence.
|
|
445
|
+
// Flags live only as long as the bounded flights; no retained per-account sequence map.
|
|
446
|
+
if (afterDispatchSequence !== undefined) {
|
|
447
|
+
for (const flight of flights ?? []) flight.state.superseded = true;
|
|
448
|
+
}
|
|
449
|
+
const state: PoolQuotaRefreshFlight["state"] = {
|
|
450
|
+
startCredentialGeneration: record?.generation,
|
|
451
|
+
validatePending,
|
|
452
|
+
};
|
|
453
|
+
const refresh = fetchFreshPoolAccountQuota(
|
|
454
|
+
accountId,
|
|
455
|
+
existing,
|
|
456
|
+
configuredPlan,
|
|
457
|
+
generation => { state.resolvedCredentialGeneration = generation; },
|
|
458
|
+
getValidToken,
|
|
459
|
+
{
|
|
460
|
+
onDispatch: sequence => { state.dispatchSequence = sequence; },
|
|
461
|
+
mayPublish: () => state.superseded !== true,
|
|
462
|
+
},
|
|
463
|
+
).then(async result => {
|
|
464
|
+
// A passive flight has consumed its validation decision. Remove it before
|
|
465
|
+
// promise settlement queues other continuations, so a late explicit caller
|
|
466
|
+
// starts fresh work instead of setting an intent nobody will read again.
|
|
467
|
+
if (!state.validatePending) {
|
|
468
|
+
releaseFlight();
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
// Only an explicit account-list refresh finishes deferred registration. Passive quota
|
|
472
|
+
// polls and startup priming remain read-only with respect to inference spending.
|
|
473
|
+
const generation = result.freshCredentialGeneration;
|
|
474
|
+
const record = state.validatePending ? readCodexAccountRecord(accountId) : null;
|
|
475
|
+
if (record?.codexValidationPending && record.credential && record.deletedAt == null
|
|
476
|
+
&& generation !== undefined && record.generation === generation
|
|
477
|
+
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
|
|
478
|
+
try {
|
|
479
|
+
await warmCodexAccount({
|
|
480
|
+
accessToken: record.credential.accessToken,
|
|
481
|
+
chatgptAccountId: record.credential.chatgptAccountId,
|
|
482
|
+
});
|
|
483
|
+
markCodexAccountValidated(accountId, Date.now(), generation);
|
|
484
|
+
clearAccountNeedsReauth(accountId, generation);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
// Keep the durable restriction on any failed/partial inference response, even
|
|
487
|
+
// when WHAM just reported headroom. No raw upstream text enters diagnostics.
|
|
488
|
+
const reason = codexWarmupFailureReason(error);
|
|
489
|
+
if (reason === "http_status:401" || reason === "http_status:403") {
|
|
490
|
+
markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation });
|
|
491
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), generation);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return result;
|
|
496
|
+
});
|
|
497
|
+
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
498
|
+
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
499
|
+
activeFlights.add(flight);
|
|
500
|
+
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
|
|
501
|
+
const releaseFlight = () => {
|
|
502
|
+
activeFlights.delete(flight);
|
|
503
|
+
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
|
|
504
|
+
poolQuotaRefreshInFlight.delete(accountId);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
try {
|
|
508
|
+
return await refresh;
|
|
509
|
+
} finally {
|
|
510
|
+
releaseFlight();
|
|
511
|
+
}
|
|
512
|
+
}
|