@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,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex V2 conversation lineage: root, parent, child, grandchild (#4546, wp8).
|
|
3
|
+
*
|
|
4
|
+
* The pool affinity key used to prefer `x-codex-parent-thread-id`, which collapsed two different
|
|
5
|
+
* identities into one map. A root bound under `app:HMAC(session, thread)` while every child bound
|
|
6
|
+
* under the RAW parent id, so siblings shared one binding entry unrelated to the root's, and a
|
|
7
|
+
* grandchild keyed on its own parent landed on a key nobody had ever bound. The proxy therefore
|
|
8
|
+
* treated one workflow as unrelated strangers even while the provider saw a single prompt-cache
|
|
9
|
+
* family.
|
|
10
|
+
*
|
|
11
|
+
* This module records the real relation -- each thread's own conversation key, its immediate
|
|
12
|
+
* parent's thread id, and the transitive root -- scoped per authenticated caller, bounded, and
|
|
13
|
+
* process-local like the binding map it feeds.
|
|
14
|
+
*
|
|
15
|
+
* What it is for, and what it is not for:
|
|
16
|
+
*
|
|
17
|
+
* - FIRST PLACEMENT. A child with no binding of its own may start where its family is already
|
|
18
|
+
* warm; see `pickLineageServingAccount` in ./routing. Once bound, the child is an ordinary
|
|
19
|
+
* binding, so a later move of the parent does not drag it.
|
|
20
|
+
* - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
|
|
21
|
+
* answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
|
|
22
|
+
* onto the root. No budget is implemented here.
|
|
23
|
+
* - WORKER CLASSIFICATION, exposed but not rewired. Admission classifies header-only today: a
|
|
24
|
+
* request naming a parent plus a distinct `thread-id` is worker traffic, and a request without
|
|
25
|
+
* `thread-id` is interactive even when it belongs to a recorded fan-out.
|
|
26
|
+
* {@link codexLineageWorkflowLane} is the lineage-backed answer a later lane consumes.
|
|
27
|
+
*
|
|
28
|
+
* Scope is an HMAC of the caller's Authorization header under a process-local key, the same
|
|
29
|
+
* posture as the affinity key itself. Two callers presenting identical thread ids can never read
|
|
30
|
+
* each other's lineage, and no raw identifier or durable hash is stored.
|
|
31
|
+
*
|
|
32
|
+
* LIFETIME, stated plainly because the word "affinity" invites the opposite assumption: none of
|
|
33
|
+
* this survives the process. The binding map is in memory, and the HMAC key above is fresh random
|
|
34
|
+
* bytes taken at module load, so a restart does not merely forget the table -- it makes yesterday's
|
|
35
|
+
* keys unreproducible. This is a warm-start hint for the life of one proxy process, never durable
|
|
36
|
+
* account ownership, and nothing here should be read as a promise to a conversation that outlives
|
|
37
|
+
* a restart.
|
|
38
|
+
*
|
|
39
|
+
* The one upgrade that is neither a fresh start nor an untouched process is a code swap under a
|
|
40
|
+
* live conversation, where the binding map is still populated with entries made under the
|
|
41
|
+
* pre-#4546 RAW parent key. Silently rebinding those cold is the exact defect this module exists
|
|
42
|
+
* to prevent, so a request that names a parent carries {@link CodexThreadLineage.legacyConversationKey}
|
|
43
|
+
* -- the key the old rule would have returned -- and routing adopts that binding once under the new
|
|
44
|
+
* key and retires the legacy entry. It is a one-way migration, not a second lookup path.
|
|
45
|
+
*/
|
|
46
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
47
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
48
|
+
|
|
49
|
+
const CODEX_LINEAGE_COMPONENT_MAX_BYTES = 512;
|
|
50
|
+
const CODEX_LINEAGE_KEY = randomBytes(32);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mirrors `CODEX_THREAD_AFFINITY_IDLE_TTL_MS` in ./routing. Deliberately duplicated rather than
|
|
54
|
+
* imported: lineage is a leaf module, and a value import from the routing module that consumes it
|
|
55
|
+
* would turn an erased type-only edge into a real cycle.
|
|
56
|
+
*/
|
|
57
|
+
export const CODEX_LINEAGE_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
58
|
+
/** Records per authenticated scope, on the order of the binding map's own 2048-entry cap. */
|
|
59
|
+
export const CODEX_LINEAGE_MAX_ENTRIES = 2048;
|
|
60
|
+
/** Distinct authenticated callers retained. Without this the scope map is the unbounded one. */
|
|
61
|
+
export const CODEX_LINEAGE_MAX_SCOPES = 64;
|
|
62
|
+
/** Sibling hints kept per parent, most recently used first. */
|
|
63
|
+
export const CODEX_LINEAGE_MAX_SIBLINGS = 8;
|
|
64
|
+
|
|
65
|
+
const LOCAL_LINEAGE_SCOPE = "local";
|
|
66
|
+
|
|
67
|
+
export type CodexWorkflowLane = "worker" | "interactive";
|
|
68
|
+
|
|
69
|
+
interface CodexLineageRecord {
|
|
70
|
+
threadId: string;
|
|
71
|
+
/** This thread's own pool binding key, byte-identical to what `codexPoolAffinityKey` returns. */
|
|
72
|
+
conversationKey: string;
|
|
73
|
+
/** Immediate parent's raw thread id, retained once seen even if a later turn omits the header. */
|
|
74
|
+
parentThreadId?: string;
|
|
75
|
+
/** Topmost ancestor's conversation key: a grandchild resolves to the root's, not its parent's. */
|
|
76
|
+
rootSessionKey: string;
|
|
77
|
+
lastUsedAt: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface CodexLineageScope {
|
|
81
|
+
/** threadId -> record, iterated oldest-first so TTL pruning and eviction stay amortised O(1). */
|
|
82
|
+
records: Map<string, CodexLineageRecord>;
|
|
83
|
+
/** conversationKey -> threadId, so cost attribution is a lookup instead of a scan. */
|
|
84
|
+
threadIdByConversationKey: Map<string, string>;
|
|
85
|
+
/** parentThreadId -> child thread ids, most recent first, capped. */
|
|
86
|
+
childThreadIdsByParent: Map<string, string[]>;
|
|
87
|
+
lastUsedAt: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What placement and cost attribution are allowed to see. `parentConversationKey` is the parent's
|
|
92
|
+
* OWN binding key -- either recorded, or derived from the shared session when the parent has not
|
|
93
|
+
* been seen yet -- never the raw header value the old affinity key returned.
|
|
94
|
+
*/
|
|
95
|
+
export interface CodexThreadLineage {
|
|
96
|
+
readonly conversationKey: string;
|
|
97
|
+
readonly rootSessionKey: string;
|
|
98
|
+
readonly parentThreadId?: string;
|
|
99
|
+
readonly parentConversationKey?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The key the pre-#4546 rule would have returned for this request -- the RAW parent id -- when
|
|
102
|
+
* that differs from the key it binds under now. Present so routing can adopt a binding left by
|
|
103
|
+
* the old rule exactly once; see the lifetime note at the top of this file.
|
|
104
|
+
*/
|
|
105
|
+
readonly legacyConversationKey?: string;
|
|
106
|
+
/** Siblings under the same declared parent, most recently used first. */
|
|
107
|
+
readonly siblingConversationKeys: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The identity the pool affinity key and the lineage record are both derived from. */
|
|
111
|
+
export interface CodexConversationIdentity {
|
|
112
|
+
readonly conversationKey: string;
|
|
113
|
+
/** Thread id this request records under: its own, or the parent's on a parent-only request. */
|
|
114
|
+
readonly recordThreadId: string;
|
|
115
|
+
readonly sessionId?: string;
|
|
116
|
+
readonly parentThreadId?: string;
|
|
117
|
+
/** Raw parent id, i.e. the key the pre-#4546 rule returned for this request. */
|
|
118
|
+
readonly legacyConversationKey?: string;
|
|
119
|
+
/** True only when the request names a parent distinct from its own thread. */
|
|
120
|
+
readonly declaresParent: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lineageByScope = new Map<string, CodexLineageScope>();
|
|
124
|
+
|
|
125
|
+
/** A record is only evidence while it is live; an idle-expired one answers like no record. */
|
|
126
|
+
function liveLineageRecord(
|
|
127
|
+
scope: CodexLineageScope | undefined,
|
|
128
|
+
threadId: string,
|
|
129
|
+
now: number,
|
|
130
|
+
): CodexLineageRecord | undefined {
|
|
131
|
+
const record = scope?.records.get(threadId);
|
|
132
|
+
return record !== undefined && now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
|
|
133
|
+
? record
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function boundedLineageComponent(value: string | null): string | undefined {
|
|
138
|
+
const normalized = value?.trim();
|
|
139
|
+
if (!normalized) return undefined;
|
|
140
|
+
if (retainedUtf8Bytes(normalized) > CODEX_LINEAGE_COMPONENT_MAX_BYTES) return undefined;
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The single derivation behind both the pool affinity key and lineage records. Keeping it here,
|
|
146
|
+
* rather than duplicated at the call site, is what guarantees a record's `conversationKey` is
|
|
147
|
+
* byte-identical to the key the thread actually binds under.
|
|
148
|
+
*/
|
|
149
|
+
export function codexConversationKeyFor(familyId: string, threadId: string): string {
|
|
150
|
+
return `app:${createHmac("sha256", CODEX_LINEAGE_KEY)
|
|
151
|
+
.update("opencodex-app-pool-affinity-v1\0")
|
|
152
|
+
.update(familyId)
|
|
153
|
+
.update("\0")
|
|
154
|
+
.update(threadId)
|
|
155
|
+
.digest("base64url")}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a request's conversation identity, or undefined when it carries no bindable thread
|
|
160
|
+
* identity at all.
|
|
161
|
+
*
|
|
162
|
+
* The set of requests that produce NO key is deliberately unchanged from the pre-#4546 rule: a
|
|
163
|
+
* bare `thread-id` with neither a session nor a parent stays unbound, exactly as the Desktop
|
|
164
|
+
* fallback required both halves of its pair. Only the VALUE moves, and only for requests that
|
|
165
|
+
* name a parent:
|
|
166
|
+
*
|
|
167
|
+
* - root (`session-id` + `thread-id`) -> HMAC(session, thread), unchanged;
|
|
168
|
+
* - child (parent + own `thread-id`) -> HMAC(session ?? parent, thread), previously the raw parent
|
|
169
|
+
* id, which is what made siblings share one entry and made a child's first turn land on a key
|
|
170
|
+
* the root had never bound;
|
|
171
|
+
* - parent-only (no `thread-id`) -> the parent's OWN recorded key when this scope has one, and
|
|
172
|
+
* otherwise HMAC(session ?? parent, parent).
|
|
173
|
+
*
|
|
174
|
+
* That last case is the one with a trap in it. A parent-only turn belongs to the parent's
|
|
175
|
+
* conversation, so it has to land on the binding the parent is already using -- but the parent's
|
|
176
|
+
* key is HMAC(session, thread), and HMAC(parent, parent) reproduces it only when the session id
|
|
177
|
+
* and the thread id are the same string. Codex's own root happens to satisfy that, which is
|
|
178
|
+
* exactly why deriving the key looks correct until a caller whose session differs from its thread
|
|
179
|
+
* starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
|
|
180
|
+
* way through. So the recorded key wins, the session-derived key is the fallback that reproduces
|
|
181
|
+
* it when the parent has not been seen in this scope, and the raw parent id is never the answer.
|
|
182
|
+
*/
|
|
183
|
+
export function codexConversationIdentity(
|
|
184
|
+
headers: Headers,
|
|
185
|
+
now = Date.now(),
|
|
186
|
+
): CodexConversationIdentity | undefined {
|
|
187
|
+
const threadId = boundedLineageComponent(headers.get("thread-id"));
|
|
188
|
+
const sessionId = boundedLineageComponent(headers.get("session-id"));
|
|
189
|
+
const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
|
|
190
|
+
|
|
191
|
+
if (threadId === undefined) {
|
|
192
|
+
if (parentThreadId === undefined) return undefined;
|
|
193
|
+
const recorded = liveLineageRecord(
|
|
194
|
+
lineageByScope.get(codexLineageScopeKey(headers)),
|
|
195
|
+
parentThreadId,
|
|
196
|
+
now,
|
|
197
|
+
);
|
|
198
|
+
return {
|
|
199
|
+
conversationKey: recorded?.conversationKey
|
|
200
|
+
?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
|
|
201
|
+
recordThreadId: parentThreadId,
|
|
202
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
203
|
+
legacyConversationKey: parentThreadId,
|
|
204
|
+
declaresParent: false,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const familyId = sessionId ?? parentThreadId;
|
|
208
|
+
if (familyId === undefined) return undefined;
|
|
209
|
+
return {
|
|
210
|
+
conversationKey: codexConversationKeyFor(familyId, threadId),
|
|
211
|
+
recordThreadId: threadId,
|
|
212
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
213
|
+
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
214
|
+
...(parentThreadId !== undefined ? { legacyConversationKey: parentThreadId } : {}),
|
|
215
|
+
declaresParent: parentThreadId !== undefined && parentThreadId !== threadId,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Which authenticated caller this request's lineage belongs to. The bearer is never stored; an
|
|
221
|
+
* unauthenticated (loopback-trusted) request lands in the single local scope.
|
|
222
|
+
*/
|
|
223
|
+
export function codexLineageScopeKey(headers: Headers): string {
|
|
224
|
+
const authorization = headers.get("authorization")?.trim();
|
|
225
|
+
if (!authorization) return LOCAL_LINEAGE_SCOPE;
|
|
226
|
+
return `auth:${createHmac("sha256", CODEX_LINEAGE_KEY)
|
|
227
|
+
.update("opencodex-lineage-scope-v1\0")
|
|
228
|
+
.update(authorization)
|
|
229
|
+
.digest("base64url")}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function dropLineageRecord(scope: CodexLineageScope, threadId: string): void {
|
|
233
|
+
const record = scope.records.get(threadId);
|
|
234
|
+
if (record === undefined) return;
|
|
235
|
+
scope.records.delete(threadId);
|
|
236
|
+
if (scope.threadIdByConversationKey.get(record.conversationKey) === threadId) {
|
|
237
|
+
scope.threadIdByConversationKey.delete(record.conversationKey);
|
|
238
|
+
}
|
|
239
|
+
if (record.parentThreadId === undefined) return;
|
|
240
|
+
const siblings = scope.childThreadIdsByParent.get(record.parentThreadId);
|
|
241
|
+
if (siblings === undefined) return;
|
|
242
|
+
const remaining = siblings.filter(id => id !== threadId);
|
|
243
|
+
if (remaining.length === 0) scope.childThreadIdsByParent.delete(record.parentThreadId);
|
|
244
|
+
else scope.childThreadIdsByParent.set(record.parentThreadId, remaining);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Records are held in least-recently-used order, so the expired ones are a prefix. */
|
|
248
|
+
function pruneLineageScope(scope: CodexLineageScope, now: number): void {
|
|
249
|
+
for (const [threadId, record] of scope.records) {
|
|
250
|
+
if (now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
|
|
251
|
+
dropLineageRecord(scope, threadId);
|
|
252
|
+
}
|
|
253
|
+
while (scope.records.size > CODEX_LINEAGE_MAX_ENTRIES) {
|
|
254
|
+
const oldest = scope.records.keys().next();
|
|
255
|
+
if (oldest.done === true) break;
|
|
256
|
+
dropLineageRecord(scope, oldest.value);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pruneLineageScopes(now: number): void {
|
|
261
|
+
for (const [scopeKey, scope] of lineageByScope) {
|
|
262
|
+
if (now - scope.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
|
|
263
|
+
lineageByScope.delete(scopeKey);
|
|
264
|
+
}
|
|
265
|
+
while (lineageByScope.size > CODEX_LINEAGE_MAX_SCOPES) {
|
|
266
|
+
const oldest = lineageByScope.keys().next();
|
|
267
|
+
if (oldest.done === true) break;
|
|
268
|
+
lineageByScope.delete(oldest.value);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function touchLineageScope(scopeKey: string, now: number): CodexLineageScope {
|
|
273
|
+
const existing = lineageByScope.get(scopeKey);
|
|
274
|
+
const scope: CodexLineageScope = existing ?? {
|
|
275
|
+
records: new Map<string, CodexLineageRecord>(),
|
|
276
|
+
threadIdByConversationKey: new Map<string, string>(),
|
|
277
|
+
childThreadIdsByParent: new Map<string, string[]>(),
|
|
278
|
+
lastUsedAt: now,
|
|
279
|
+
};
|
|
280
|
+
if (existing !== undefined) lineageByScope.delete(scopeKey);
|
|
281
|
+
scope.lastUsedAt = now;
|
|
282
|
+
lineageByScope.set(scopeKey, scope);
|
|
283
|
+
pruneLineageScopes(now);
|
|
284
|
+
pruneLineageScope(scope, now);
|
|
285
|
+
return scope;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The lineage view for one identity inside one scope, computed without writing anything.
|
|
290
|
+
*
|
|
291
|
+
* A parent seen for the first time through one of its children is derived rather than invented:
|
|
292
|
+
* the child knows the shared session, so HMAC(session, parent) reproduces the key the parent
|
|
293
|
+
* binds under. Once the parent has actually been recorded, its own key wins.
|
|
294
|
+
*
|
|
295
|
+
* Depth is transitive by construction -- a grandchild inherits its parent's resolved root instead
|
|
296
|
+
* of re-deriving one hop -- so a workflow never scatters across several roots.
|
|
297
|
+
*/
|
|
298
|
+
function lineageFor(
|
|
299
|
+
scope: CodexLineageScope | undefined,
|
|
300
|
+
identity: CodexConversationIdentity,
|
|
301
|
+
now: number,
|
|
302
|
+
): CodexThreadLineage {
|
|
303
|
+
const previous = liveLineageRecord(scope, identity.recordThreadId, now);
|
|
304
|
+
// A turn that omits the parent header does not orphan a thread whose parent is already known.
|
|
305
|
+
// That retention is the whole of the lineage-backed worker answer below.
|
|
306
|
+
const parentThreadId = identity.declaresParent
|
|
307
|
+
? identity.parentThreadId
|
|
308
|
+
: previous?.parentThreadId;
|
|
309
|
+
|
|
310
|
+
const parentRecord = parentThreadId !== undefined
|
|
311
|
+
? liveLineageRecord(scope, parentThreadId, now)
|
|
312
|
+
: undefined;
|
|
313
|
+
const parentConversationKey = parentThreadId === undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: parentRecord?.conversationKey
|
|
316
|
+
?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
|
|
317
|
+
const rootSessionKey = parentConversationKey === undefined
|
|
318
|
+
? identity.conversationKey
|
|
319
|
+
: parentRecord?.rootSessionKey ?? parentConversationKey;
|
|
320
|
+
|
|
321
|
+
const siblingConversationKeys: string[] = [];
|
|
322
|
+
if (parentThreadId !== undefined && scope !== undefined) {
|
|
323
|
+
for (const siblingThreadId of scope.childThreadIdsByParent.get(parentThreadId) ?? []) {
|
|
324
|
+
if (siblingThreadId === identity.recordThreadId) continue;
|
|
325
|
+
const sibling = liveLineageRecord(scope, siblingThreadId, now);
|
|
326
|
+
if (sibling !== undefined) siblingConversationKeys.push(sibling.conversationKey);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Only a key the old rule would have produced AND that this request no longer uses is a
|
|
331
|
+
// migration candidate. A root's key is unchanged, so it never carries one.
|
|
332
|
+
const legacyConversationKey = identity.legacyConversationKey !== undefined
|
|
333
|
+
&& identity.legacyConversationKey !== identity.conversationKey
|
|
334
|
+
? identity.legacyConversationKey
|
|
335
|
+
: undefined;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
conversationKey: identity.conversationKey,
|
|
339
|
+
rootSessionKey,
|
|
340
|
+
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
341
|
+
...(parentConversationKey !== undefined ? { parentConversationKey } : {}),
|
|
342
|
+
...(legacyConversationKey !== undefined ? { legacyConversationKey } : {}),
|
|
343
|
+
siblingConversationKeys,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read this request's lineage without recording it.
|
|
349
|
+
*
|
|
350
|
+
* A preview must see what the final resolution will see, but it must not be the thing that
|
|
351
|
+
* creates the record: preview runs before auth has decided whether this request may hold Pool
|
|
352
|
+
* state at all, and a record written there would outlive a decision to hold none.
|
|
353
|
+
*/
|
|
354
|
+
export function resolveCodexThreadLineage(
|
|
355
|
+
headers: Headers,
|
|
356
|
+
now = Date.now(),
|
|
357
|
+
): CodexThreadLineage | undefined {
|
|
358
|
+
const identity = codexConversationIdentity(headers, now);
|
|
359
|
+
if (identity === undefined) return undefined;
|
|
360
|
+
return lineageFor(lineageByScope.get(codexLineageScopeKey(headers)), identity, now);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Record this request's thread relation and return the resolved lineage. */
|
|
364
|
+
export function recordCodexThreadLineage(
|
|
365
|
+
headers: Headers,
|
|
366
|
+
now = Date.now(),
|
|
367
|
+
): CodexThreadLineage | undefined {
|
|
368
|
+
const identity = codexConversationIdentity(headers, now);
|
|
369
|
+
if (identity === undefined) return undefined;
|
|
370
|
+
const scope = touchLineageScope(codexLineageScopeKey(headers), now);
|
|
371
|
+
const lineage = lineageFor(scope, identity, now);
|
|
372
|
+
const parentThreadId = lineage.parentThreadId;
|
|
373
|
+
|
|
374
|
+
// Re-insert rather than mutate: the records map doubles as the LRU order.
|
|
375
|
+
dropLineageRecord(scope, identity.recordThreadId);
|
|
376
|
+
scope.records.set(identity.recordThreadId, {
|
|
377
|
+
threadId: identity.recordThreadId,
|
|
378
|
+
conversationKey: identity.conversationKey,
|
|
379
|
+
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
380
|
+
rootSessionKey: lineage.rootSessionKey,
|
|
381
|
+
lastUsedAt: now,
|
|
382
|
+
});
|
|
383
|
+
scope.threadIdByConversationKey.set(identity.conversationKey, identity.recordThreadId);
|
|
384
|
+
if (parentThreadId !== undefined) {
|
|
385
|
+
const siblings = (scope.childThreadIdsByParent.get(parentThreadId) ?? [])
|
|
386
|
+
.filter(id => id !== identity.recordThreadId);
|
|
387
|
+
siblings.unshift(identity.recordThreadId);
|
|
388
|
+
scope.childThreadIdsByParent.set(parentThreadId, siblings.slice(0, CODEX_LINEAGE_MAX_SIBLINGS));
|
|
389
|
+
}
|
|
390
|
+
pruneLineageScope(scope, now);
|
|
391
|
+
|
|
392
|
+
return lineage;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Cost-attribution lookup for other layers: which root workflow owns this conversation key.
|
|
397
|
+
* Scoped like the records, so a caller can only ever resolve inside its own scope, and read-only
|
|
398
|
+
* -- reading a lineage for accounting must not extend its lifetime.
|
|
399
|
+
*/
|
|
400
|
+
export function codexThreadLineageLookup(
|
|
401
|
+
conversationKey: string,
|
|
402
|
+
scopeKey: string,
|
|
403
|
+
now = Date.now(),
|
|
404
|
+
): { conversationKey: string; rootSessionKey: string; parentThreadId?: string } | undefined {
|
|
405
|
+
const scope = lineageByScope.get(scopeKey);
|
|
406
|
+
if (scope === undefined) return undefined;
|
|
407
|
+
const threadId = scope.threadIdByConversationKey.get(conversationKey);
|
|
408
|
+
if (threadId === undefined) return undefined;
|
|
409
|
+
const record = scope.records.get(threadId);
|
|
410
|
+
if (record === undefined || now - record.lastUsedAt > CODEX_LINEAGE_IDLE_TTL_MS) return undefined;
|
|
411
|
+
return {
|
|
412
|
+
conversationKey: record.conversationKey,
|
|
413
|
+
rootSessionKey: record.rootSessionKey,
|
|
414
|
+
...(record.parentThreadId !== undefined ? { parentThreadId: record.parentThreadId } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The root a request's spend belongs to, for a caller holding headers rather than a key. An
|
|
420
|
+
* unrecorded conversation is its own root, so this never answers null for a bindable request and
|
|
421
|
+
* an accounting layer has no reason to invent one.
|
|
422
|
+
*/
|
|
423
|
+
export function codexLineageRootForRequest(headers: Headers, now = Date.now()): string | undefined {
|
|
424
|
+
const identity = codexConversationIdentity(headers, now);
|
|
425
|
+
if (identity === undefined) return undefined;
|
|
426
|
+
return codexThreadLineageLookup(identity.conversationKey, codexLineageScopeKey(headers), now)
|
|
427
|
+
?.rootSessionKey
|
|
428
|
+
?? identity.conversationKey;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The lineage-backed worker/interactive answer.
|
|
433
|
+
*
|
|
434
|
+
* Admission classifies header-only today: a request is worker traffic only when it names a parent
|
|
435
|
+
* AND a distinct `thread-id`, so a fan-out turn that stopped sending the parent header reads as
|
|
436
|
+
* interactive. This keeps that rule and adds what the headers could not say -- a thread already
|
|
437
|
+
* recorded with a parent is worker traffic. Nothing here changes admission; a later lane consumes
|
|
438
|
+
* it.
|
|
439
|
+
*/
|
|
440
|
+
export function codexLineageWorkflowLane(headers: Headers, now = Date.now()): CodexWorkflowLane {
|
|
441
|
+
const threadId = boundedLineageComponent(headers.get("thread-id"));
|
|
442
|
+
const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
|
|
443
|
+
if (parentThreadId !== undefined && threadId !== undefined && threadId !== parentThreadId) {
|
|
444
|
+
return "worker";
|
|
445
|
+
}
|
|
446
|
+
if (threadId === undefined) return "interactive";
|
|
447
|
+
const record = lineageByScope.get(codexLineageScopeKey(headers))?.records.get(threadId);
|
|
448
|
+
return record !== undefined
|
|
449
|
+
&& now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
|
|
450
|
+
&& record.parentThreadId !== undefined
|
|
451
|
+
? "worker"
|
|
452
|
+
: "interactive";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Test-only reset; production state is process-local and dies with the process. */
|
|
456
|
+
export function clearCodexThreadLineageForTests(): void {
|
|
457
|
+
lineageByScope.clear();
|
|
458
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Per-account cooldown for a stored Codex pool credential whose forced refresh
|
|
4
|
+
* failed without proving the grant is dead.
|
|
5
|
+
*
|
|
6
|
+
* A token-endpoint 5xx, a generation CAS loss, or a network blip is transient
|
|
7
|
+
* (#2887): it must not quarantine the account or drop its binding. Retrying the
|
|
8
|
+
* same doomed refresh on every request, though, is how a single unhealthy
|
|
9
|
+
* account pinned the pool at 503 while healthy siblings sat idle. Consecutive
|
|
10
|
+
* non-terminal failures open a bounded growing cooldown; during that window no
|
|
11
|
+
* new forced refresh starts, and selection prefers a sibling. The first
|
|
12
|
+
* successful refresh clears it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fallbackCodexAccountLogLabel } from "./account-label";
|
|
16
|
+
|
|
17
|
+
export const CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON = "codex_pool_refresh_incomplete";
|
|
18
|
+
|
|
19
|
+
/** Growing delays between forced-refresh attempts for one account. */
|
|
20
|
+
export const CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
|
|
21
|
+
|
|
22
|
+
export class CodexPoolRefreshCooldownError extends Error {
|
|
23
|
+
readonly retryable = true;
|
|
24
|
+
readonly code = "CODEX_REFRESH_COOLING";
|
|
25
|
+
|
|
26
|
+
constructor(message = "Codex credential refresh is cooling down") {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CodexPoolRefreshCooldownError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type RefreshFailureBackoff = {
|
|
33
|
+
consecutiveFailures: number;
|
|
34
|
+
cooldownUntil: number;
|
|
35
|
+
reason: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const backoffByAccount = new Map<string, RefreshFailureBackoff>();
|
|
39
|
+
/**
|
|
40
|
+
* Bumped whenever an account's failures are cleared because something proved them obsolete — a
|
|
41
|
+
* successful refresh, or a replacement credential written by login/reauth. A refresh flight that
|
|
42
|
+
* started before that moment is reporting on a grant that no longer exists, and its late failure
|
|
43
|
+
* must not re-quarantine the credential that replaced it.
|
|
44
|
+
*/
|
|
45
|
+
const fenceByAccount = new Map<string, number>();
|
|
46
|
+
let nowOverride: number | undefined;
|
|
47
|
+
|
|
48
|
+
export function setCodexPoolRefreshFailureNowForTests(now?: number): void {
|
|
49
|
+
nowOverride = now;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resetCodexPoolRefreshFailureBackoffForTests(): void {
|
|
53
|
+
backoffByAccount.clear();
|
|
54
|
+
fenceByAccount.clear();
|
|
55
|
+
nowOverride = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The value a refresh flight captures before it starts, to be handed back on failure. */
|
|
59
|
+
export function codexPoolRefreshFence(accountId: string): number {
|
|
60
|
+
return fenceByAccount.get(accountId) ?? 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clearCodexPoolRefreshFailure(accountId: string): void {
|
|
64
|
+
backoffByAccount.delete(accountId);
|
|
65
|
+
fenceByAccount.set(accountId, (fenceByAccount.get(accountId) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop every remembered failure. Called when the routing layer discards its per-account state,
|
|
70
|
+
* because a cooldown outliving the binding it was learned alongside would keep an account out of
|
|
71
|
+
* selection for a roster the operator has already replaced.
|
|
72
|
+
*/
|
|
73
|
+
export function clearAllCodexPoolRefreshFailures(): void {
|
|
74
|
+
backoffByAccount.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function currentNow(now?: number): number {
|
|
78
|
+
return now ?? nowOverride ?? Date.now();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function delayFor(consecutiveFailures: number): number {
|
|
82
|
+
const index = Math.min(Math.max(consecutiveFailures, 1), CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS.length) - 1;
|
|
83
|
+
return CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS[index]!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How many consecutive non-terminal failures must land before a refresh is WITHHELD.
|
|
88
|
+
*
|
|
89
|
+
* Withholding on the first failure was wrong twice over. A single token-endpoint blip is the
|
|
90
|
+
* ordinary case that the very next attempt clears, and -- worse -- a withheld refresh never runs,
|
|
91
|
+
* so an account whose grant is actually revoked can no longer discover that: the terminal 401 it
|
|
92
|
+
* owes the operator turns into a retryable 503 that never resolves. The cooldown exists for the
|
|
93
|
+
* account that keeps failing, not for the one that failed once.
|
|
94
|
+
*/
|
|
95
|
+
export const CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES = 3;
|
|
96
|
+
|
|
97
|
+
export function getCodexPoolRefreshCooldownUntil(accountId: string, now = currentNow()): number | null {
|
|
98
|
+
const entry = backoffByAccount.get(accountId);
|
|
99
|
+
if (!entry) return null;
|
|
100
|
+
if (entry.consecutiveFailures < CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES) return null;
|
|
101
|
+
return entry.cooldownUntil > now ? entry.cooldownUntil : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isCodexPoolRefreshCooling(accountId: string, now = currentNow()): boolean {
|
|
105
|
+
return getCodexPoolRefreshCooldownUntil(accountId, now) !== null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Record a non-terminal forced-refresh failure. Already-cooling accounts do not
|
|
110
|
+
* grow the window: growth requires another real attempt after the previous one
|
|
111
|
+
* expired. Logs the classified reason once per account per window, with the
|
|
112
|
+
* durable hash label — never a token and never an email.
|
|
113
|
+
*/
|
|
114
|
+
export function noteCodexPoolRefreshFailure(
|
|
115
|
+
accountId: string,
|
|
116
|
+
reason: string,
|
|
117
|
+
now = currentNow(),
|
|
118
|
+
fence?: number,
|
|
119
|
+
): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } {
|
|
120
|
+
const existing = backoffByAccount.get(accountId);
|
|
121
|
+
// A flight that started before the account's failures were cleared is speaking for a grant
|
|
122
|
+
// that has since been replaced or proven healthy. Recording it would put the new credential
|
|
123
|
+
// back in the quarantine its predecessor earned.
|
|
124
|
+
if (fence !== undefined && fence !== codexPoolRefreshFence(accountId)) {
|
|
125
|
+
return {
|
|
126
|
+
consecutiveFailures: existing?.consecutiveFailures ?? 0,
|
|
127
|
+
cooldownUntil: existing?.cooldownUntil ?? 0,
|
|
128
|
+
openedWindow: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// The "do not grow inside an open window" rule applies only once the window is actually
|
|
132
|
+
// WITHHOLDING. Below the threshold no refresh is being withheld, so every failure is a real
|
|
133
|
+
// attempt that really failed and must count -- otherwise a client retrying the 503 once a
|
|
134
|
+
// second can never reach the threshold the cooldown is meant to protect against.
|
|
135
|
+
const withholding = existing !== undefined
|
|
136
|
+
&& existing.consecutiveFailures >= CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES;
|
|
137
|
+
if (existing && withholding && existing.cooldownUntil > now) {
|
|
138
|
+
return {
|
|
139
|
+
consecutiveFailures: existing.consecutiveFailures,
|
|
140
|
+
cooldownUntil: existing.cooldownUntil,
|
|
141
|
+
openedWindow: false,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
|
|
145
|
+
const cooldownUntil = now + delayFor(consecutiveFailures);
|
|
146
|
+
backoffByAccount.set(accountId, { consecutiveFailures, cooldownUntil, reason });
|
|
147
|
+
const label = fallbackCodexAccountLogLabel(accountId);
|
|
148
|
+
console.warn(
|
|
149
|
+
`[codex-auth] Codex pool account ${label} credential refresh failed (${reason})`,
|
|
150
|
+
);
|
|
151
|
+
return { consecutiveFailures, cooldownUntil, openedWindow: true };
|
|
152
|
+
}
|