@bitkyc08/opencodex 2.1.8 → 2.1.10

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.
@@ -0,0 +1,130 @@
1
+ export type StoredAccountQuota = {
2
+ weeklyPercent?: number;
3
+ fiveHourPercent?: number;
4
+ monthlyPercent?: number;
5
+ weeklyResetAt?: number;
6
+ fiveHourResetAt?: number;
7
+ monthlyResetAt?: number;
8
+ updatedAt: number;
9
+ };
10
+
11
+ export type WhamUsageResponse = {
12
+ email?: string | null;
13
+ plan_type?: string | null;
14
+ rate_limit?: {
15
+ primary_window?: { used_percent?: number; reset_at?: number };
16
+ secondary_window?: { used_percent?: number; reset_at?: number };
17
+ tertiary_window?: { used_percent?: number; reset_at?: number };
18
+ };
19
+ };
20
+
21
+ const accountQuota = new Map<string, StoredAccountQuota>();
22
+
23
+ export const CODEX_UNKNOWN_USAGE_SCORE = 100;
24
+
25
+ export function normalizeUsagePercent(value: unknown): number | undefined {
26
+ const numeric = typeof value === "number"
27
+ ? value
28
+ : typeof value === "string" && value.trim() !== ""
29
+ ? Number(value)
30
+ : undefined;
31
+ if (typeof numeric !== "number" || !Number.isFinite(numeric)) return undefined;
32
+ return Math.max(0, Math.min(100, numeric));
33
+ }
34
+
35
+ function normalizeResetAt(value: unknown): number | undefined {
36
+ const numeric = typeof value === "number"
37
+ ? value
38
+ : typeof value === "string" && value.trim() !== ""
39
+ ? Number(value)
40
+ : undefined;
41
+ if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric < 0) return undefined;
42
+ return numeric;
43
+ }
44
+
45
+ function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
46
+ return [quota.weeklyPercent, quota.fiveHourPercent, quota.monthlyPercent]
47
+ .some(value => typeof value === "number" && Number.isFinite(value));
48
+ }
49
+
50
+ export function updateAccountQuota(
51
+ accountId: string,
52
+ weekly: unknown,
53
+ fiveHour: unknown,
54
+ weeklyResetAt?: unknown,
55
+ fiveHourResetAt?: unknown,
56
+ monthly?: unknown,
57
+ monthlyResetAt?: unknown,
58
+ ): void {
59
+ const existing = accountQuota.get(accountId);
60
+ const nextWeekly = normalizeUsagePercent(weekly);
61
+ const nextFiveHour = normalizeUsagePercent(fiveHour);
62
+ const nextMonthly = normalizeUsagePercent(monthly);
63
+ if (nextWeekly === undefined && nextFiveHour === undefined && nextMonthly === undefined) return;
64
+
65
+ const quota: StoredAccountQuota = {
66
+ ...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}),
67
+ ...(existing?.fiveHourPercent !== undefined ? { fiveHourPercent: existing.fiveHourPercent } : {}),
68
+ ...(existing?.monthlyPercent !== undefined ? { monthlyPercent: existing.monthlyPercent } : {}),
69
+ ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
70
+ ...(existing?.fiveHourResetAt !== undefined ? { fiveHourResetAt: existing.fiveHourResetAt } : {}),
71
+ ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
72
+ updatedAt: Date.now(),
73
+ };
74
+
75
+ const nextWeeklyResetAt = normalizeResetAt(weeklyResetAt);
76
+ const nextFiveHourResetAt = normalizeResetAt(fiveHourResetAt);
77
+ const nextMonthlyResetAt = normalizeResetAt(monthlyResetAt);
78
+ if (nextWeekly !== undefined) {
79
+ quota.weeklyPercent = nextWeekly;
80
+ if (nextWeeklyResetAt !== undefined) quota.weeklyResetAt = nextWeeklyResetAt;
81
+ }
82
+ if (nextFiveHour !== undefined) {
83
+ quota.fiveHourPercent = nextFiveHour;
84
+ if (nextFiveHourResetAt !== undefined) quota.fiveHourResetAt = nextFiveHourResetAt;
85
+ }
86
+ if (nextMonthly !== undefined) {
87
+ quota.monthlyPercent = nextMonthly;
88
+ if (nextMonthlyResetAt !== undefined) quota.monthlyResetAt = nextMonthlyResetAt;
89
+ }
90
+
91
+ accountQuota.set(accountId, quota);
92
+ }
93
+
94
+ export function getAccountQuota(accountId: string): StoredAccountQuota | null {
95
+ return accountQuota.get(accountId) ?? null;
96
+ }
97
+
98
+ export function listAccountQuotas(): IterableIterator<[string, StoredAccountQuota]> {
99
+ return accountQuota.entries();
100
+ }
101
+
102
+ export function clearAccountQuota(accountId?: string): void {
103
+ if (accountId) accountQuota.delete(accountId);
104
+ else accountQuota.clear();
105
+ }
106
+
107
+ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
108
+ if (!data.rate_limit) return null;
109
+ const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
110
+ const weeklyPercent = normalizeUsagePercent(data.rate_limit.secondary_window?.used_percent);
111
+ const fiveHourPercent = normalizeUsagePercent(data.rate_limit.primary_window?.used_percent);
112
+ const monthlyPercent = normalizeUsagePercent(data.rate_limit.tertiary_window?.used_percent);
113
+ const weeklyResetAt = normalizeResetAt(data.rate_limit.secondary_window?.reset_at);
114
+ const fiveHourResetAt = normalizeResetAt(data.rate_limit.primary_window?.reset_at);
115
+ const monthlyResetAt = normalizeResetAt(data.rate_limit.tertiary_window?.reset_at);
116
+ if (weeklyPercent !== undefined) {
117
+ quota.weeklyPercent = weeklyPercent;
118
+ if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
119
+ }
120
+ if (fiveHourPercent !== undefined) {
121
+ quota.fiveHourPercent = fiveHourPercent;
122
+ if (fiveHourResetAt !== undefined) quota.fiveHourResetAt = fiveHourResetAt;
123
+ }
124
+ if (monthlyPercent !== undefined) {
125
+ quota.monthlyPercent = monthlyPercent;
126
+ if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
127
+ }
128
+
129
+ return hasKnownQuotaValue(quota) ? quota : null;
130
+ }
@@ -0,0 +1,382 @@
1
+ import { saveConfig } from "./config";
2
+ import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./codex-account-store";
3
+ import { codexAccountLogLabel } from "./codex-account-label";
4
+ import { isCodexAccountUsable } from "./codex-account-usability";
5
+ import { isAccountNeedsReauth, markAccountNeedsReauth } from "./codex-account-runtime-state";
6
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./codex-quota";
7
+ import type { OcxConfig } from "./types";
8
+
9
+ type ThreadAffinityEntry = {
10
+ accountId: string;
11
+ generation: number;
12
+ createdAt: number;
13
+ lastUsedAt: number;
14
+ };
15
+
16
+ export type CodexThreadResolution =
17
+ | { status: "selected"; accountId: string }
18
+ | { status: "none" }
19
+ | { status: "expired"; accountId: string };
20
+
21
+ const threadAccountMap = new Map<string, ThreadAffinityEntry>();
22
+ type CodexUpstreamHealth = {
23
+ consecutiveFailures: number;
24
+ lastFailureStatus?: number;
25
+ lastFailureAt?: number;
26
+ cooldownUntil?: number;
27
+ };
28
+
29
+ const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
30
+ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
31
+ export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
32
+ export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
33
+ export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
34
+
35
+ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
36
+
37
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
38
+ export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
39
+ export type CodexUpstreamOutcomeMeta = {
40
+ retryAfter?: string | null;
41
+ resetAt?: unknown | unknown[];
42
+ now?: number;
43
+ };
44
+
45
+ function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
46
+ return (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId);
47
+ }
48
+
49
+ export function clearThreadAccountMap(): void {
50
+ threadAccountMap.clear();
51
+ }
52
+
53
+ export function clearThreadAccountMapForAccount(accountId: string): void {
54
+ for (const [threadId, entry] of threadAccountMap) {
55
+ if (entry.accountId === accountId) threadAccountMap.delete(threadId);
56
+ }
57
+ }
58
+
59
+ export function clearCodexUpstreamHealth(): void {
60
+ upstreamHealth.clear();
61
+ }
62
+
63
+ export function clearCodexUpstreamHealthForAccount(accountId: string): void {
64
+ upstreamHealth.delete(accountId);
65
+ }
66
+
67
+ export function getCodexUpstreamHealth(
68
+ accountId: string,
69
+ ): CodexUpstreamHealth | null {
70
+ return upstreamHealth.get(accountId) ?? null;
71
+ }
72
+
73
+ export function computeCodexUsageScore(quota: {
74
+ weeklyPercent?: number;
75
+ fiveHourPercent?: number;
76
+ monthlyPercent?: number;
77
+ } | null): number {
78
+ if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
79
+ const values = [quota.weeklyPercent, quota.fiveHourPercent, quota.monthlyPercent]
80
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
81
+ return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
82
+ }
83
+
84
+ export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
85
+ if (outcome === "connect_error" || outcome === "timeout") return "transient";
86
+ if (!Number.isFinite(outcome)) return "unknown";
87
+ if (outcome >= 200 && outcome < 300) return "success";
88
+ if (outcome === 401 || outcome === 403) return "credential";
89
+ if (outcome === 429) return "quota";
90
+ if (outcome >= 400 && outcome < 500) return "caller";
91
+ if (outcome >= 500 && outcome < 600) return "transient";
92
+ return "unknown";
93
+ }
94
+
95
+ function clampCooldownMs(ms: number): number {
96
+ return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
97
+ }
98
+
99
+ export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
100
+ const text = value?.trim();
101
+ if (!text) return undefined;
102
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
103
+ const seconds = Number(text);
104
+ if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
105
+ }
106
+ const timestamp = Date.parse(text);
107
+ if (!Number.isFinite(timestamp)) return undefined;
108
+ const delay = timestamp - now;
109
+ return delay > 0 ? clampCooldownMs(delay) : undefined;
110
+ }
111
+
112
+ function resetTimestampMs(value: unknown): number | undefined {
113
+ const numeric = typeof value === "number"
114
+ ? value
115
+ : typeof value === "string" && value.trim() !== ""
116
+ ? Number(value)
117
+ : undefined;
118
+ if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
119
+ return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
120
+ }
121
+
122
+ export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
123
+ const values = Array.isArray(resetAt) ? resetAt : [resetAt];
124
+ let best: number | undefined;
125
+ for (const value of values) {
126
+ const timestamp = resetTimestampMs(value);
127
+ if (timestamp === undefined) continue;
128
+ const delay = timestamp - now;
129
+ if (delay <= 0) continue;
130
+ const clamped = clampCooldownMs(delay);
131
+ if (best === undefined || clamped < best) best = clamped;
132
+ }
133
+ return best;
134
+ }
135
+
136
+ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
137
+ const now = meta.now ?? Date.now();
138
+ const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
139
+ const resetCooldownMs = retryAfterMs === undefined ? parseResetCooldownMs(meta.resetAt, now) : undefined;
140
+ return now + (retryAfterMs ?? resetCooldownMs ?? CODEX_DEFAULT_QUOTA_COOLDOWN_MS);
141
+ }
142
+
143
+ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
144
+ const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
145
+ return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
146
+ }
147
+
148
+ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
149
+ return getCodexAccountCooldownUntil(accountId, now) !== null;
150
+ }
151
+
152
+ function isCodexAccountSelectable(config: OcxConfig, accountId: string, now: number): boolean {
153
+ return !isCodexAccountInCooldown(accountId, now) && isCodexAccountUsable(config, accountId);
154
+ }
155
+
156
+ function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
157
+ return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS;
158
+ }
159
+
160
+ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
161
+ return isCodexAccountGenerationLive(entry.accountId, entry.generation);
162
+ }
163
+
164
+ function pruneExpiredThreadAffinities(now: number): void {
165
+ for (const [threadId, entry] of threadAccountMap) {
166
+ if (isThreadAffinityExpired(entry, now)) threadAccountMap.delete(threadId);
167
+ }
168
+ }
169
+
170
+ function pruneLruThreadAffinities(): void {
171
+ while (threadAccountMap.size > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
172
+ let oldestThreadId: string | null = null;
173
+ let oldestLastUsedAt = Number.POSITIVE_INFINITY;
174
+ for (const [threadId, entry] of threadAccountMap) {
175
+ if (entry.lastUsedAt < oldestLastUsedAt) {
176
+ oldestThreadId = threadId;
177
+ oldestLastUsedAt = entry.lastUsedAt;
178
+ }
179
+ }
180
+ if (!oldestThreadId) return;
181
+ threadAccountMap.delete(oldestThreadId);
182
+ }
183
+ }
184
+
185
+ function bindThreadAffinity(threadId: string, accountId: string, now: number): void {
186
+ const record = readCodexAccountRecord(accountId);
187
+ if (!record?.credential || record.deletedAt != null) return;
188
+ pruneExpiredThreadAffinities(now);
189
+ const previous = threadAccountMap.get(threadId);
190
+ threadAccountMap.set(threadId, {
191
+ accountId,
192
+ generation: record.generation,
193
+ createdAt: previous?.createdAt ?? now,
194
+ lastUsedAt: now,
195
+ });
196
+ pruneLruThreadAffinities();
197
+ }
198
+
199
+ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Date.now()): string[] {
200
+ return (config.codexAccounts ?? [])
201
+ .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id))
202
+ .filter(account => !isCodexAccountInCooldown(account.id, now))
203
+ .filter(account => isCodexAccountUsable(config, account.id))
204
+ .map(account => account.id);
205
+ }
206
+
207
+ function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: number, now: number): string {
208
+ let best = active;
209
+ let bestUsage = activeUsage;
210
+ for (const id of getEligiblePoolAccounts(config, active, now)) {
211
+ const usage = computeCodexUsageScore(getAccountQuota(id));
212
+ if (usage < bestUsage) {
213
+ best = id;
214
+ bestUsage = usage;
215
+ }
216
+ }
217
+ return best;
218
+ }
219
+
220
+ export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: string, now = Date.now()): string | null {
221
+ let best: string | null = null;
222
+ let bestUsage = Number.POSITIVE_INFINITY;
223
+ for (const id of getEligiblePoolAccounts(config, excludeId, now)) {
224
+ const usage = computeCodexUsageScore(getAccountQuota(id));
225
+ if (usage < bestUsage) {
226
+ best = id;
227
+ bestUsage = usage;
228
+ }
229
+ }
230
+ return best;
231
+ }
232
+
233
+ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
234
+ if (config.activeCodexAccountId === accountId) return;
235
+ config.activeCodexAccountId = accountId;
236
+ saveConfig(config);
237
+ }
238
+
239
+ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string {
240
+ const threshold = config.autoSwitchThreshold ?? 80;
241
+ if (threshold <= 0) return active;
242
+ const quota = getAccountQuota(active);
243
+ const activeUsage = computeCodexUsageScore(quota);
244
+ if (activeUsage < threshold) return active;
245
+ const best = pickLowerUsageAccount(config, active, activeUsage, now);
246
+ if (best !== active) setActiveCodexAccount(config, best);
247
+ return best;
248
+ }
249
+
250
+ function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
251
+ const threshold = config.upstreamFailoverThreshold ?? 3;
252
+ if (threshold <= 0) return false;
253
+ const health = upstreamHealth.get(accountId);
254
+ if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
255
+ return !!health && health.consecutiveFailures >= threshold;
256
+ }
257
+
258
+ function applyFailureFailover(config: OcxConfig, active: string, now: number): string {
259
+ if (!shouldFailover(config, active, now)) return active;
260
+ const best = pickLowestUsageCodexAccount(config, active, now);
261
+ if (best) {
262
+ setActiveCodexAccount(config, best);
263
+ return best;
264
+ }
265
+ return active;
266
+ }
267
+
268
+ export function resolveCodexAccountForThread(
269
+ threadId: string | null,
270
+ config: OcxConfig,
271
+ now = Date.now(),
272
+ ): string | null {
273
+ const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now);
274
+ return resolution.status === "selected" ? resolution.accountId : null;
275
+ }
276
+
277
+ export function resolveCodexAccountForThreadDetailed(
278
+ threadId: string | null,
279
+ config: OcxConfig,
280
+ now = Date.now(),
281
+ ): CodexThreadResolution {
282
+ if (threadId && threadAccountMap.has(threadId)) {
283
+ const entry = threadAccountMap.get(threadId)!;
284
+ if (isThreadAffinityExpired(entry, now)) {
285
+ threadAccountMap.delete(threadId);
286
+ return { status: "expired", accountId: entry.accountId };
287
+ }
288
+ if (
289
+ isThreadAffinityGenerationLive(entry)
290
+ && isCodexAccountSelectable(config, entry.accountId, now)
291
+ ) {
292
+ entry.lastUsedAt = now;
293
+ return { status: "selected", accountId: entry.accountId };
294
+ }
295
+ threadAccountMap.delete(threadId);
296
+ }
297
+ let active = config.activeCodexAccountId;
298
+ if (!active) return { status: "none" };
299
+ if (!isCodexAccountSelectable(config, active, now)) {
300
+ const fallback = pickLowestUsageCodexAccount(config, active, now);
301
+ if (fallback) {
302
+ setActiveCodexAccount(config, fallback);
303
+ active = fallback;
304
+ } else if (hasConfiguredPoolAccount(config, active)) {
305
+ return { status: "selected", accountId: active };
306
+ } else {
307
+ return { status: "none" };
308
+ }
309
+ }
310
+ active = applyQuotaAutoSwitch(config, active, now);
311
+ active = applyFailureFailover(config, active, now);
312
+ if (!isCodexAccountUsable(config, active)) {
313
+ return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
314
+ }
315
+ if (isCodexAccountInCooldown(active, now)) {
316
+ return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
317
+ }
318
+ if (threadId) bindThreadAffinity(threadId, active, now);
319
+ return { status: "selected", accountId: active };
320
+ }
321
+
322
+ export function recordCodexUpstreamOutcome(
323
+ config: OcxConfig,
324
+ accountId: string | null,
325
+ outcome: CodexUpstreamOutcome,
326
+ meta: CodexUpstreamOutcomeMeta = {},
327
+ ): void {
328
+ if (!accountId) return;
329
+ const now = meta.now ?? Date.now();
330
+ const outcomeClass = classifyCodexUpstreamOutcome(outcome);
331
+ if (outcomeClass === "success") {
332
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
333
+ if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, cooldownUntil });
334
+ else upstreamHealth.delete(accountId);
335
+ return;
336
+ }
337
+ if (outcomeClass === "caller") return;
338
+
339
+ const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
340
+ if (outcomeClass === "credential") {
341
+ upstreamHealth.set(accountId, {
342
+ consecutiveFailures: 1,
343
+ lastFailureStatus,
344
+ lastFailureAt: now,
345
+ });
346
+ markAccountNeedsReauth(accountId);
347
+ clearThreadAccountMapForAccount(accountId);
348
+ return;
349
+ }
350
+
351
+ if (outcomeClass === "quota") {
352
+ upstreamHealth.set(accountId, {
353
+ consecutiveFailures: 0,
354
+ lastFailureStatus,
355
+ lastFailureAt: now,
356
+ cooldownUntil: computeQuotaCooldownUntil(meta),
357
+ });
358
+ clearThreadAccountMapForAccount(accountId);
359
+ if (config.activeCodexAccountId === accountId) {
360
+ const fallback = pickLowestUsageCodexAccount(config, accountId, now);
361
+ if (fallback) setActiveCodexAccount(config, fallback);
362
+ }
363
+ return;
364
+ }
365
+
366
+ const current = upstreamHealth.get(accountId);
367
+ const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
368
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
369
+ upstreamHealth.set(accountId, {
370
+ consecutiveFailures: stale ? 1 : (current?.consecutiveFailures ?? 0) + 1,
371
+ lastFailureStatus,
372
+ lastFailureAt: now,
373
+ ...(cooldownUntil ? { cooldownUntil } : {}),
374
+ });
375
+ if (config.activeCodexAccountId === accountId) applyFailureFailover(config, accountId, now);
376
+ }
377
+
378
+ export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {
379
+ if (!accountId) return providerName;
380
+ const account = (config.codexAccounts ?? []).find(a => !a.isMain && a.id === accountId);
381
+ return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName;
382
+ }
@@ -0,0 +1,57 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { WsData } from "./ws-bridge";
3
+
4
+ const socketsByAccount = new Map<string, Set<ServerWebSocket<WsData>>>();
5
+
6
+ function trackedAccountId(ws: ServerWebSocket<WsData>): string | null {
7
+ const ctx = ws.data.authContext;
8
+ return ctx?.kind === "pool" ? ctx.accountId : null;
9
+ }
10
+
11
+ export function registerCodexWebSocket(ws: ServerWebSocket<WsData>): void {
12
+ const accountId = trackedAccountId(ws);
13
+ if (!accountId) return;
14
+ let sockets = socketsByAccount.get(accountId);
15
+ if (!sockets) {
16
+ sockets = new Set();
17
+ socketsByAccount.set(accountId, sockets);
18
+ }
19
+ sockets.add(ws);
20
+ }
21
+
22
+ export function unregisterCodexWebSocket(ws: ServerWebSocket<WsData>): void {
23
+ const accountId = trackedAccountId(ws);
24
+ if (!accountId) return;
25
+ const sockets = socketsByAccount.get(accountId);
26
+ if (!sockets) return;
27
+ sockets.delete(ws);
28
+ if (sockets.size === 0) socketsByAccount.delete(accountId);
29
+ }
30
+
31
+ export function invalidateCodexWebSocketsForAccount(accountId: string): number {
32
+ const sockets = socketsByAccount.get(accountId);
33
+ if (!sockets) return 0;
34
+ const snapshot = [...sockets];
35
+ socketsByAccount.delete(accountId);
36
+ for (const ws of snapshot) {
37
+ try {
38
+ ws.data.cancel?.();
39
+ } catch {
40
+ /* ignore cancel callbacks during invalidation */
41
+ }
42
+ try {
43
+ ws.close(4001, "Codex account invalidated");
44
+ } catch {
45
+ /* socket may already be closing */
46
+ }
47
+ }
48
+ return snapshot.length;
49
+ }
50
+
51
+ export function getTrackedCodexWebSocketCountForAccount(accountId: string): number {
52
+ return socketsByAccount.get(accountId)?.size ?? 0;
53
+ }
54
+
55
+ export function clearCodexWebSocketRegistry(): void {
56
+ socketsByAccount.clear();
57
+ }