@askalf/dario 6.4.0 → 6.5.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.
@@ -61,14 +61,88 @@ export declare function _resetCodexRefreshFailuresForTest(): void;
61
61
  * a misleading "run `dario login`" answer to the client.
62
62
  */
63
63
  export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
64
+ /** Record that `alias` declined, for as long as the upstream asked. */
65
+ export declare function noteCodexDecline(alias: string, retryAfterMs?: number | null): number;
66
+ /** A seat that just served is not rate-limited — clear it. */
67
+ export declare function clearCodexDecline(alias: string): void;
68
+ /** Ms until `alias` is askable again; 0 when it is askable now. */
69
+ export declare function codexCooldownRemainingMs(alias: string): number;
70
+ /** Test seam — forget every cool-down and binding. */
71
+ export declare function _resetCodexPoolForTest(): void;
72
+ /** The alias currently bound to a conversation, or null. */
73
+ export declare function codexStickyAliasFor(key: string | null | undefined): string | null;
64
74
  /**
65
- * Pick the account to serve a request. Single account is the expected case (one
66
- * ChatGPT subscription); with several, `DARIO_CODEX_ACCOUNT` names one and
67
- * otherwise the first alphabetically wins. No rotation/least-recently-used
68
- * balancing a subscription is per-seat, so spreading load across seats is the
69
- * user's decision to make explicitly, not something to do implicitly.
75
+ * Move a conversation onto `alias`, the codex mirror of pool.rebindSticky.
76
+ *
77
+ * Selection binds a conversation to the seat it picked; mid-request failover
78
+ * then moves it, and without this the binding still names the seat that just
79
+ * declined the next turn would read a stale binding, find it cooling, and
80
+ * re-pick from scratch. A null key is accepted so the caller does not have to
81
+ * guard: a request with no hashable first user message has no conversation to
82
+ * bind.
83
+ */
84
+ export declare function rebindCodexSticky(key: string | null | undefined, alias: string): void;
85
+ /**
86
+ * Choose a ChatGPT seat for this request.
87
+ *
88
+ * Order, most specific first:
89
+ * 1. an explicitly named alias (`x-dario-account`, DARIO_CODEX_ACCOUNT) — a
90
+ * pin is an instruction, so it is honoured even while cooling; the caller
91
+ * asked for that seat and gets its answer, 429 included.
92
+ * 2. the seat this conversation is already bound to, unless it is cooling.
93
+ * 3. the first seat alphabetically that is not cooling — deterministic, so a
94
+ * given conversation lands on the same seat across a restart and keeps its
95
+ * prompt cache.
96
+ * 4. null when every seat is cooling. The caller answers from that rather
97
+ * than spending a request that can only 429 again.
98
+ */
99
+ export declare function selectCodexAccount(preferredAlias?: string, opts?: {
100
+ stickyKey?: string | null;
101
+ }): Promise<CodexAccountCredentials | null>;
102
+ /**
103
+ * The next askable seat that this request has NOT already tried.
104
+ *
105
+ * Mid-flight failover: a seat that 429s during a request hands the SAME
106
+ * request to a peer rather than failing it. Without this the pool only helps
107
+ * the request AFTER the one that discovered the limit — the discovering
108
+ * request still failed, every time a window rolled over.
109
+ *
110
+ * `tried` is per-request, so a seat already attempted here is never revisited
111
+ * inside the same request even if its cool-down has not landed yet. That is
112
+ * the codex mirror of the Claude pool's selectExcluding, and it is what makes
113
+ * the loop terminate: every pass adds a seat, so it is bounded by pool size.
114
+ *
115
+ * Stickiness is deliberately NOT consulted. The bound seat is the one that
116
+ * just declined; re-offering it would loop, and a conversation whose seat has
117
+ * gone away is better served elsewhere than not at all.
118
+ */
119
+ export declare function selectCodexAccountExcluding(tried: ReadonlySet<string>): Promise<CodexAccountCredentials | null>;
120
+ /** Every seat is cooling — the fail-fast condition, for the caller's message. */
121
+ /**
122
+ * Are ALL of these aliases cooling, right now?
123
+ *
124
+ * Synchronous on purpose. The provider-wide cool-down is written from this
125
+ * answer, and an `await` between deciding and writing is a window another
126
+ * in-flight request can use: the last-limited-seat request observes every seat
127
+ * cooling, a peer then succeeds on a just-recovered seat and calls
128
+ * clearCodexDecline, and the delayed continuation re-cools the whole provider
129
+ * against a pool that is healthy again. canAttempt('codex') then short-circuits
130
+ * and the healthy seat is skipped until the stale window expires — the exact
131
+ * single-seat outage this pool exists to prevent, reintroduced by the
132
+ * bookkeeping meant to prevent it.
133
+ *
134
+ * Re-checking inside the continuation narrows that window; it does not close
135
+ * it, because the re-check is itself another await. Taking the alias list first
136
+ * and then deciding-and-writing with no suspension point between them closes it
137
+ * outright: JS runs that callback as one unit, so nothing can interleave.
138
+ *
139
+ * The alias list may be a tick stale, which is harmless — a seat added in that
140
+ * window is not cooling, so "all cooled" is false on the next decline anyway.
70
141
  */
71
- export declare function selectCodexAccount(preferredAlias?: string): Promise<CodexAccountCredentials | null>;
142
+ export declare function allAliasesCooled(aliases: readonly string[]): boolean;
143
+ export declare function allCodexAccountsCooled(): Promise<boolean>;
144
+ /** Longest remaining cool-down across every seat, for a `retry-after`. */
145
+ export declare function codexPoolRetryAfterMs(): Promise<number>;
72
146
  /**
73
147
  * Parse whatever the user pastes back after authorizing.
74
148
  *
@@ -15,6 +15,7 @@ import { homedir } from 'node:os';
15
15
  import { randomBytes } from 'node:crypto';
16
16
  import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, CodexRefreshError, } from './codex-oauth.js';
17
17
  import { durableWriteFile } from './durable-write.js';
18
+ import { ProviderCooldowns } from './provider-cooldown.js';
18
19
  const DARIO_DIR = join(homedir(), '.dario');
19
20
  const CODEX_ACCOUNTS_DIR = join(DARIO_DIR, 'codex-accounts');
20
21
  /** Same alias charset/traversal guard as accounts.ts's safeAliasPath. */
@@ -298,7 +299,105 @@ export async function getFreshCodexAccount(creds) {
298
299
  * balancing — a subscription is per-seat, so spreading load across seats is the
299
300
  * user's decision to make explicitly, not something to do implicitly.
300
301
  */
301
- export async function selectCodexAccount(preferredAlias) {
302
+ /**
303
+ * Per-seat cool-downs and conversation stickiness for the ChatGPT pool
304
+ * (dario#1244 follow-up).
305
+ *
306
+ * Until now selectCodexAccount returned `sort()[0]` — the alphabetically FIRST
307
+ * account, every time. `dario add altman` will happily store a dozen seats and
308
+ * dario would use exactly one of them. That is why the account-wide 429 on
309
+ * 2026-09-07 took the whole GPT lane down: a second seat sat there, healthy and
310
+ * unreachable, while every request failed over to Claude.
311
+ *
312
+ * Reuses ProviderCooldowns keyed by ALIAS rather than by provider name. It is
313
+ * already the right shape — arbitrary string key, injectable clock, entries
314
+ * dropped on read — so the pool needs no second cool-down implementation.
315
+ *
316
+ * ROTATION IS PER-CONVERSATION, NOT PER-REQUEST, and that is the whole design.
317
+ * The Codex prompt cache is scoped to the serving account: a conversation that
318
+ * builds a prefix on seat A reads nothing from it on seat B, and measured cache
319
+ * share on this lane is 59% in production against a 73% controlled ceiling.
320
+ * Rotating per request would trade a rate-limit problem for a cache problem and
321
+ * come out behind. So a conversation binds to a seat and stays there until that
322
+ * seat actually declines.
323
+ *
324
+ * Deliberately NOT headroom routing like the Claude pool. Claude responds with
325
+ * `anthropic-ratelimit-*` headers on every response, so that pool can read
326
+ * utilisation before it picks. The Codex backend states nothing until it 429s —
327
+ * the only signal is the decline itself plus its `retry-after` — so this is
328
+ * fill-first with cool-down eviction, which is what the available signal
329
+ * supports. If the backend ever starts reporting utilisation, this is where
330
+ * headroom would go.
331
+ */
332
+ let codexCooldowns = new ProviderCooldowns();
333
+ /** conversation sticky key -> alias. Bounded; swept when it exceeds the cap. */
334
+ const codexSticky = new Map();
335
+ const CODEX_STICKY_MAX = 500;
336
+ /** Record that `alias` declined, for as long as the upstream asked. */
337
+ export function noteCodexDecline(alias, retryAfterMs) {
338
+ return codexCooldowns.note(alias, retryAfterMs);
339
+ }
340
+ /** A seat that just served is not rate-limited — clear it. */
341
+ export function clearCodexDecline(alias) {
342
+ codexCooldowns.clear(alias);
343
+ }
344
+ /** Ms until `alias` is askable again; 0 when it is askable now. */
345
+ export function codexCooldownRemainingMs(alias) {
346
+ return codexCooldowns.remainingMs(alias);
347
+ }
348
+ /** Test seam — forget every cool-down and binding. */
349
+ export function _resetCodexPoolForTest() {
350
+ codexSticky.clear();
351
+ // A fresh instance rather than clearing per alias: the previous version
352
+ // emptied the sticky map first and then iterated it, so it cleared nothing
353
+ // and cool-downs leaked between test cases.
354
+ codexCooldowns = new ProviderCooldowns();
355
+ }
356
+ /** The alias currently bound to a conversation, or null. */
357
+ export function codexStickyAliasFor(key) {
358
+ return key ? codexSticky.get(key) ?? null : null;
359
+ }
360
+ function bindCodexSticky(key, alias) {
361
+ if (codexSticky.size >= CODEX_STICKY_MAX && !codexSticky.has(key)) {
362
+ // Oldest-first eviction: Map preserves insertion order, so the first key is
363
+ // the least recently bound. Losing a binding costs one cache miss, never
364
+ // correctness, so a cheap sweep beats an LRU.
365
+ const oldest = codexSticky.keys().next().value;
366
+ if (oldest !== undefined)
367
+ codexSticky.delete(oldest);
368
+ }
369
+ codexSticky.set(key, alias);
370
+ }
371
+ /**
372
+ * Move a conversation onto `alias`, the codex mirror of pool.rebindSticky.
373
+ *
374
+ * Selection binds a conversation to the seat it picked; mid-request failover
375
+ * then moves it, and without this the binding still names the seat that just
376
+ * declined — the next turn would read a stale binding, find it cooling, and
377
+ * re-pick from scratch. A null key is accepted so the caller does not have to
378
+ * guard: a request with no hashable first user message has no conversation to
379
+ * bind.
380
+ */
381
+ export function rebindCodexSticky(key, alias) {
382
+ if (!key)
383
+ return;
384
+ bindCodexSticky(key, alias);
385
+ }
386
+ /**
387
+ * Choose a ChatGPT seat for this request.
388
+ *
389
+ * Order, most specific first:
390
+ * 1. an explicitly named alias (`x-dario-account`, DARIO_CODEX_ACCOUNT) — a
391
+ * pin is an instruction, so it is honoured even while cooling; the caller
392
+ * asked for that seat and gets its answer, 429 included.
393
+ * 2. the seat this conversation is already bound to, unless it is cooling.
394
+ * 3. the first seat alphabetically that is not cooling — deterministic, so a
395
+ * given conversation lands on the same seat across a restart and keeps its
396
+ * prompt cache.
397
+ * 4. null when every seat is cooling. The caller answers from that rather
398
+ * than spending a request that can only 429 again.
399
+ */
400
+ export async function selectCodexAccount(preferredAlias, opts) {
302
401
  const alias = preferredAlias || process.env.DARIO_CODEX_ACCOUNT;
303
402
  if (alias) {
304
403
  const one = await loadCodexAccount(alias);
@@ -308,7 +407,83 @@ export async function selectCodexAccount(preferredAlias) {
308
407
  const all = await loadAllCodexAccounts();
309
408
  if (all.length === 0)
310
409
  return null;
311
- return [...all].sort((a, b) => a.alias.localeCompare(b.alias))[0];
410
+ const byAlias = [...all].sort((a, b) => a.alias.localeCompare(b.alias));
411
+ const key = opts?.stickyKey ?? null;
412
+ if (key) {
413
+ const bound = codexSticky.get(key);
414
+ if (bound && !codexCooldowns.isCooled(bound)) {
415
+ const hit = byAlias.find((c) => c.alias === bound);
416
+ // A binding to a seat that has since been removed falls through to a
417
+ // fresh pick rather than failing the request.
418
+ if (hit)
419
+ return hit;
420
+ codexSticky.delete(key);
421
+ }
422
+ }
423
+ const free = byAlias.find((c) => !codexCooldowns.isCooled(c.alias));
424
+ if (!free)
425
+ return null;
426
+ if (key)
427
+ bindCodexSticky(key, free.alias);
428
+ return free;
429
+ }
430
+ /**
431
+ * The next askable seat that this request has NOT already tried.
432
+ *
433
+ * Mid-flight failover: a seat that 429s during a request hands the SAME
434
+ * request to a peer rather than failing it. Without this the pool only helps
435
+ * the request AFTER the one that discovered the limit — the discovering
436
+ * request still failed, every time a window rolled over.
437
+ *
438
+ * `tried` is per-request, so a seat already attempted here is never revisited
439
+ * inside the same request even if its cool-down has not landed yet. That is
440
+ * the codex mirror of the Claude pool's selectExcluding, and it is what makes
441
+ * the loop terminate: every pass adds a seat, so it is bounded by pool size.
442
+ *
443
+ * Stickiness is deliberately NOT consulted. The bound seat is the one that
444
+ * just declined; re-offering it would loop, and a conversation whose seat has
445
+ * gone away is better served elsewhere than not at all.
446
+ */
447
+ export async function selectCodexAccountExcluding(tried) {
448
+ const all = await loadAllCodexAccounts();
449
+ if (all.length === 0)
450
+ return null;
451
+ return [...all]
452
+ .sort((a, b) => a.alias.localeCompare(b.alias))
453
+ .find((c) => !tried.has(c.alias) && !codexCooldowns.isCooled(c.alias)) ?? null;
454
+ }
455
+ /** Every seat is cooling — the fail-fast condition, for the caller's message. */
456
+ /**
457
+ * Are ALL of these aliases cooling, right now?
458
+ *
459
+ * Synchronous on purpose. The provider-wide cool-down is written from this
460
+ * answer, and an `await` between deciding and writing is a window another
461
+ * in-flight request can use: the last-limited-seat request observes every seat
462
+ * cooling, a peer then succeeds on a just-recovered seat and calls
463
+ * clearCodexDecline, and the delayed continuation re-cools the whole provider
464
+ * against a pool that is healthy again. canAttempt('codex') then short-circuits
465
+ * and the healthy seat is skipped until the stale window expires — the exact
466
+ * single-seat outage this pool exists to prevent, reintroduced by the
467
+ * bookkeeping meant to prevent it.
468
+ *
469
+ * Re-checking inside the continuation narrows that window; it does not close
470
+ * it, because the re-check is itself another await. Taking the alias list first
471
+ * and then deciding-and-writing with no suspension point between them closes it
472
+ * outright: JS runs that callback as one unit, so nothing can interleave.
473
+ *
474
+ * The alias list may be a tick stale, which is harmless — a seat added in that
475
+ * window is not cooling, so "all cooled" is false on the next decline anyway.
476
+ */
477
+ export function allAliasesCooled(aliases) {
478
+ return aliases.length > 0 && aliases.every((a) => codexCooldowns.isCooled(a));
479
+ }
480
+ export async function allCodexAccountsCooled() {
481
+ return allAliasesCooled(await listCodexAccountAliases());
482
+ }
483
+ /** Longest remaining cool-down across every seat, for a `retry-after`. */
484
+ export async function codexPoolRetryAfterMs() {
485
+ const aliases = await listCodexAccountAliases();
486
+ return aliases.reduce((max, a) => Math.max(max, codexCooldowns.remainingMs(a)), 0);
312
487
  }
313
488
  /**
314
489
  * Parse whatever the user pastes back after authorizing.
@@ -48,6 +48,9 @@ export interface CodexForwardOutcome {
48
48
  export interface CodexDecline {
49
49
  status: number;
50
50
  retryAfterMs: number | null;
51
+ /** The seat that declined. Without it a caller can cool the provider but
52
+ * not the account, which is the whole point of a pool. */
53
+ alias: string;
51
54
  }
52
55
  /** The cached slug list for an alias WITHOUT fetching. For the admin surface:
53
56
  * a status read must never cost an upstream call or a token refresh. */
@@ -275,7 +278,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
275
278
  * into a buffered response object is not built yet. A non-streaming client
276
279
  * gets a 400 saying so.
277
280
  */
278
- export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void): Promise<boolean>;
281
+ export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void,
282
+ /** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
283
+ * caller needs to know which seat and for how long — without it the pool
284
+ * cannot cool a limited seat on this path, so selection hands the same
285
+ * rate-limited account back on every following request. */
286
+ onDecline?: (info: CodexDecline) => void,
287
+ /** When true a decline returns false WITHOUT writing, so the caller can
288
+ * retry the request on a healthy peer. False keeps the old behaviour: the
289
+ * upstream error is written through as the backend sent it. */
290
+ deferOnUnavailable?: boolean): Promise<boolean>;
279
291
  /**
280
292
  * Serve a request from a stored Codex account, in either client wire shape.
281
293
  *
@@ -759,7 +759,16 @@ export function buildCodexHeaders(creds) {
759
759
  * into a buffered response object is not built yet. A non-streaming client
760
760
  * gets a 400 saying so.
761
761
  */
762
- export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone) {
762
+ export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone,
763
+ /** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
764
+ * caller needs to know which seat and for how long — without it the pool
765
+ * cannot cool a limited seat on this path, so selection hands the same
766
+ * rate-limited account back on every following request. */
767
+ onDecline,
768
+ /** When true a decline returns false WITHOUT writing, so the caller can
769
+ * retry the request on a healthy peer. False keeps the old behaviour: the
770
+ * upstream error is written through as the backend sent it. */
771
+ deferOnUnavailable = false) {
763
772
  const startedAt = Date.now();
764
773
  const model = String(body.model ?? '');
765
774
  let reported = false;
@@ -804,6 +813,23 @@ export async function forwardResponsesToCodex(res, body, creds, corsOrigin, secu
804
813
  const detail = await upstream.text().catch(() => '');
805
814
  if (verbose)
806
815
  console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
816
+ // Same rule as the Messages path: a 429 or a 5xx is the seat declining,
817
+ // and that is true whether or not anything is waiting to take over.
818
+ const unavailable = upstream.status === 429 || upstream.status >= 500;
819
+ if (unavailable) {
820
+ try {
821
+ onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
822
+ }
823
+ catch { /* a reporting failure must never break a request */ }
824
+ }
825
+ if (deferOnUnavailable && unavailable) {
826
+ if (verbose)
827
+ console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring`);
828
+ // Nothing written, so the caller is free to retry this same request
829
+ // on a peer. Reporting nothing here matches forwardToCodex: a
830
+ // declined attempt is not a served request.
831
+ return false;
832
+ }
807
833
  if (!clientGone) {
808
834
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
809
835
  // The backend's own error body, already in the client's shape.
@@ -1048,16 +1074,25 @@ midstream) {
1048
1074
  // own fault (a bad body, an unsupported parameter) is NOT: failing over
1049
1075
  // would just reproduce it somewhere else and hide the real error.
1050
1076
  const unavailable = upstream.status === 429 || upstream.status >= 500;
1077
+ // The seat said no, and that is true whether or not a fallback exists
1078
+ // to defer to. Recording it outside the defer branch is what lets the
1079
+ // POOL rotate on a deployment with no --pool-fallback configured: with
1080
+ // the notice inside the branch, a 429 went straight to the client and
1081
+ // the seat was never cooled, so selection returned the same limited
1082
+ // account forever (found writing the proxy-level test for #1288).
1083
+ if (unavailable) {
1084
+ try {
1085
+ onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
1086
+ }
1087
+ catch { /* a reporting failure must never break a request */ }
1088
+ }
1051
1089
  if (deferOnUnavailable && unavailable) {
1052
1090
  console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring to the next provider`);
1053
1091
  // A decline is the only exit that tells the caller nothing was served,
1054
1092
  // and until now it carried no WHY: a 429 and a 503 were the same false.
1055
1093
  // The chain needs the status (to cool a rate limit but not an outage)
1056
1094
  // and the upstream's own `retry-after` (to cool it for the right long).
1057
- try {
1058
- onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')) });
1059
- }
1060
- catch { /* a reporting failure must never break a declined request */ }
1095
+ // (the decline was already recorded above, for both exits)
1061
1096
  return false;
1062
1097
  }
1063
1098
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
@@ -1251,7 +1286,7 @@ midstream) {
1251
1286
  // status 0: no HTTP status ever arrived. Reported so the caller can tell
1252
1287
  // an outage from a rate limit — an unreachable backend is not quota.
1253
1288
  try {
1254
- onDecline?.({ status: 0, retryAfterMs: null });
1289
+ onDecline?.({ status: 0, retryAfterMs: null, alias: creds.alias });
1255
1290
  }
1256
1291
  catch { /* as above */ }
1257
1292
  return false;
package/dist/proxy.js CHANGED
@@ -31,7 +31,7 @@ import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequest
31
31
  import { isClaudeServableModel } from './claude-model.js';
32
32
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
33
33
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
34
- import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
34
+ import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
35
35
  import { route as routeProvider } from './provider-adapter.js';
36
36
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
37
37
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
@@ -163,6 +163,30 @@ function extractFirstUserMessage(body) {
163
163
  }
164
164
  return '';
165
165
  }
166
+ /**
167
+ * The conversation key for Codex seat stickiness, from raw request bytes.
168
+ *
169
+ * The same hash the Claude pool binds on (computeStickyKey over the first user
170
+ * message), so a conversation stays on one ChatGPT seat across turns and keeps
171
+ * the prompt-cache prefix it built there — rotating per request would trade a
172
+ * rate-limit problem for a cache problem.
173
+ *
174
+ * Null for a body that is not a JSON object or carries no user message; those
175
+ * requests bypass stickiness rather than sharing one bucket. Used by the two
176
+ * codex entries that hold no parsed body of their own (the pool-exhausted
177
+ * fallback and the mid-stream continuation target).
178
+ */
179
+ function codexStickyKeyForBody(body) {
180
+ try {
181
+ const parsed = JSON.parse(body.toString('utf-8'));
182
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
183
+ return null;
184
+ return computeStickyKey(extractFirstUserMessage(parsed));
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
166
190
  // Session ID behavior:
167
191
  // v3.18 rotated per request — which was itself a fingerprint. Real CC
168
192
  // rotates roughly once per conversation, not per call. A user who has
@@ -1999,6 +2023,47 @@ export async function startProxy(opts = {}) {
1999
2023
  function checkAuth(req) {
2000
2024
  return authenticateRequest(req.headers, apiKeyBuf);
2001
2025
  }
2026
+ /**
2027
+ * A ChatGPT seat declined. Cool the SEAT, and the provider only once every
2028
+ * seat is cooling.
2029
+ *
2030
+ * One handler for every codex forward — both wire shapes and the
2031
+ * Claude-to-Codex fallback. It was two hand-copied copies, and that is
2032
+ * precisely how the native Responses path ended up cooling nothing while
2033
+ * the translated path cooled correctly: a third call site inherits this by
2034
+ * construction rather than by someone remembering to copy it.
2035
+ *
2036
+ * Closes over nothing per-request, which is what makes one copy possible.
2037
+ */
2038
+ const codexOnDecline = (d) => {
2039
+ // Cool the PROVIDER (is the codex lane usable at all) and the SEAT
2040
+ // that actually declined (which ChatGPT account said no, and for how
2041
+ // long). Before the seat half existed, selectCodexAccount returned the
2042
+ // alphabetically-first account every time, so one 429'd seat took the
2043
+ // whole lane down while its healthy peers sat unreachable.
2044
+ if (d.status !== 429)
2045
+ return;
2046
+ // A 429 is a SEAT-level condition, so cool the seat unconditionally.
2047
+ // The provider is only cooled once EVERY seat is cooling.
2048
+ //
2049
+ // Cooling the provider on any single 429 defeats the pool: the routing
2050
+ // gate short-circuits on canAttempt('codex'), so the next request never
2051
+ // reaches selectCodexAccount to find the healthy peer — the exact
2052
+ // single-seat outage this change exists to remove (caught in review of
2053
+ // #1288). Dropping provider cooling altogether is equally wrong the other
2054
+ // way: on a single-seat deployment nothing would fail fast, and every
2055
+ // request would re-hammer a seat already known to be limited instead of
2056
+ // falling through to Claude. All-seats-cooled is the condition that means
2057
+ // what the provider cool-down was always trying to say.
2058
+ noteCodexDecline(d.alias, d.retryAfterMs);
2059
+ void listCodexAccountAliases().then((aliases) => {
2060
+ // Decide and write in the SAME tick — see allAliasesCooled. An await
2061
+ // between the two lets a concurrent success clear a seat in the gap,
2062
+ // and the late write then cools a pool that has recovered.
2063
+ if (allAliasesCooled(aliases))
2064
+ providerCooldowns.note('codex', d.retryAfterMs);
2065
+ }).catch(() => { });
2066
+ };
2002
2067
  /**
2003
2068
  * Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
2004
2069
  *
@@ -2025,50 +2090,101 @@ export async function startProxy(opts = {}) {
2025
2090
  return false;
2026
2091
  if (!(await hasAnyCodexAccount().catch(() => false)))
2027
2092
  return false;
2028
- const stored = await selectCodexAccount().catch(() => null);
2093
+ // Sticky on the CONVERSATION, not on this fallback hop: a conversation
2094
+ // that reaches the subscription twice lands on the same seat both times,
2095
+ // so the second turn reads the prefix the first one paid to create.
2096
+ const stickyKey = codexStickyKeyForBody(body);
2097
+ const stored = await selectCodexAccount(undefined, { stickyKey }).catch(() => null);
2029
2098
  if (!stored)
2030
2099
  return false;
2031
- let creds;
2100
+ let seat;
2032
2101
  try {
2033
- creds = await getFreshCodexAccount(stored);
2102
+ seat = await getFreshCodexAccount(stored);
2034
2103
  }
2035
2104
  catch {
2036
2105
  return false;
2037
2106
  }
2038
- const slugs = await getCodexModelSlugs(creds).catch(() => []);
2039
- const fallbackPick = pickCodexFallback(fallbackModels, slugs);
2040
- if (!fallbackPick)
2041
- return false;
2042
- const fallbackModel = fallbackPick.model;
2043
- const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
2044
- if (!fallbackBody)
2045
- return false;
2046
- console.log(`[dario] #${requestCount} ${why} → codex account ${creds.alias} as ${fallbackModel}`);
2047
- requestCount++;
2048
- attempted.add('codex');
2049
2107
  // If an api-key backend could ALSO serve this request, let the subscription
2050
2108
  // decline a 429/5xx rather than answer with it, and report not-served so the
2051
- // caller falls through to that backend. This helper's contract has always
2052
- // said it declines so the caller can continue; it just never exercised the
2053
- // mechanism it was built on, so a rate-limited subscription ended the chain
2054
- // with a healthy backend sitting unused beside it.
2109
+ // caller falls through to that backend.
2055
2110
  //
2056
- // With NO next option, do not defer: the real upstream error is more useful
2057
- // to the client than replacing it with a generic 503.
2111
+ // With NO next option and no peer, do not defer: the real upstream error is
2112
+ // more useful to the client than replacing it with a generic 503.
2058
2113
  const hasNextOption = openaiBackend !== null && shape === 'openai';
2059
- const served = await forwardToCodex(req, res, fallbackBody, creds, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption, undefined,
2060
- // Only a rate limit cools the provider. A 5xx or a transport failure is
2061
- // an outage, not quota cooling it would park a provider that may be
2062
- // back on the next request, which is the opposite of the fix.
2063
- (d) => { if (d.status === 429)
2064
- providerCooldowns.note('codex', d.retryAfterMs); },
2065
- // The mirror of the Claude side (dario#1161): an operator who writes
2066
- // `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
2067
- // failover runs at, so the entry's own suffix reaches the request rather
2068
- // than the failover quietly running at the backend default.
2069
- effortForCodex(fallbackPick.effort));
2070
- if (served)
2114
+ // The next seat that could serve one of these fallback models, excluding
2115
+ // everything already tried. `peek` is the cached read, so the scan costs no
2116
+ // upstream call; a seat whose model list is unknown is still worth a try.
2117
+ //
2118
+ // Scans rather than testing one candidate: with mixed model availability
2119
+ // across seats, the alphabetically-next peer may be the one that lists none
2120
+ // of the fallback models while a later one lists one.
2121
+ const nextFallbackPeer = async (tried) => {
2122
+ const skipped = new Set(tried);
2123
+ for (;;) {
2124
+ const candidate = await selectCodexAccountExcluding(skipped).catch(() => null);
2125
+ if (!candidate)
2126
+ return null;
2127
+ const peerSlugs = peekCodexModelSlugs(candidate.alias);
2128
+ if (!peerSlugs || pickCodexFallback(fallbackModels, peerSlugs))
2129
+ return candidate;
2130
+ skipped.add(candidate.alias);
2131
+ }
2132
+ };
2133
+ // Mid-flight seat failover on the CLAUDE-TO-CODEX route, the same as the
2134
+ // primary Codex route has. Without it this route selected one seat and
2135
+ // stopped: a 429 from that seat was written to the client while a healthy
2136
+ // peer sat unused, so the pool helped every route except this one (caught
2137
+ // in review of #1288). The fallback model is re-picked per seat because
2138
+ // pickCodexFallback reads that SEAT's slugs — peers need not list the same
2139
+ // model, and the one that answers may answer as a different one.
2140
+ //
2141
+ // Terminates by construction: every pass adds a seat to `tried`, and
2142
+ // nextFallbackPeer never returns one already in it.
2143
+ const tried = new Set();
2144
+ let served = false;
2145
+ while (seat) {
2146
+ tried.add(seat.alias);
2147
+ const slugs = await getCodexModelSlugs(seat).catch(() => []);
2148
+ const fallbackPick = pickCodexFallback(fallbackModels, slugs);
2149
+ // Resolved BEFORE the attempt: it decides whether this attempt may defer,
2150
+ // and becomes the seat to retry on if it declines.
2151
+ const peer = await nextFallbackPeer(tried);
2152
+ if (!fallbackPick) {
2153
+ // This seat lists none of the fallback models. That used to end the
2154
+ // attempt outright; a peer may still list one.
2155
+ if (!peer)
2156
+ return false;
2157
+ seat = await getFreshCodexAccount(peer).catch(() => peer);
2158
+ continue;
2159
+ }
2160
+ const fallbackModel = fallbackPick.model;
2161
+ const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
2162
+ if (!fallbackBody)
2163
+ return false;
2164
+ console.log(`[dario] #${requestCount} ${why} → codex account ${seat.alias} as ${fallbackModel}`);
2165
+ requestCount++;
2166
+ // Marked only once an attempt is actually being made. Marking it before
2167
+ // the guards above would tell the rest of the request that codex had been
2168
+ // tried when it had not, suppressing a later legitimate attempt.
2169
+ attempted.add('codex');
2170
+ served = await forwardToCodex(req, res, fallbackBody, seat, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption || peer !== null, undefined, codexOnDecline,
2171
+ // The mirror of the Claude side (dario#1161): an operator who writes
2172
+ // `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
2173
+ // failover runs at, so the entry's own suffix reaches the request rather
2174
+ // than the failover quietly running at the backend default.
2175
+ effortForCodex(fallbackPick.effort));
2176
+ if (served || !peer)
2177
+ break;
2178
+ console.log(`[dario] codex seat ${seat.alias} declined — retrying this fallback on ${peer.alias}`);
2179
+ // The conversation follows the request. Its binding still names the seat
2180
+ // that just declined; leaving it there would send the next turn back to a
2181
+ // cooling seat and re-pick from scratch.
2182
+ rebindCodexSticky(stickyKey, peer.alias);
2183
+ seat = await getFreshCodexAccount(peer).catch(() => peer);
2184
+ }
2185
+ if (served) {
2071
2186
  providerCooldowns.clear('codex');
2187
+ }
2072
2188
  return served;
2073
2189
  };
2074
2190
  /**
@@ -3027,7 +3143,10 @@ export async function startProxy(opts = {}) {
3027
3143
  return null;
3028
3144
  if (!(await hasAnyCodexAccount().catch(() => false)))
3029
3145
  return null;
3030
- const stored = await selectCodexAccount().catch(() => null);
3146
+ // The CLIENT's bytes rather than the rewritten `body`: a resume is the
3147
+ // same conversation as the request that died mid-stream, so it must
3148
+ // hash to the same key and land on the seat that conversation holds.
3149
+ const stored = await selectCodexAccount(undefined, { stickyKey: codexStickyKeyForBody(clientBodyBytes) }).catch(() => null);
3031
3150
  if (!stored)
3032
3151
  return null;
3033
3152
  let creds;
@@ -3328,8 +3447,17 @@ export async function startProxy(opts = {}) {
3328
3447
  // account, rather than letting the throw escape into the JSON-peek
3329
3448
  // catch below and disappear (DEV-179a412f).
3330
3449
  let codexUnavailable = null;
3450
+ // Conversation -> seat binding for the codex lane, the mirror of the
3451
+ // Claude pool's stickyKey below. It belongs HERE because this is
3452
+ // where the seat is CHOSEN: without a key every turn independently
3453
+ // re-picks "the first seat not cooling", so a lower-alias seat that
3454
+ // frees up mid-conversation silently moves the conversation off the
3455
+ // seat holding its prompt-cache prefix (caught in review of #1288).
3456
+ // `parsedBody` is the object the invalid-body guard already parsed,
3457
+ // so this costs no second JSON.parse.
3458
+ const codexStickyKey = parsedBody ? computeStickyKey(extractFirstUserMessage(parsedBody)) : null;
3331
3459
  if (await hasAnyCodexAccount()) {
3332
- const stored = await selectCodexAccount();
3460
+ const stored = await selectCodexAccount(undefined, { stickyKey: codexStickyKey });
3333
3461
  if (stored) {
3334
3462
  try {
3335
3463
  codexCreds = await getFreshCodexAccount(stored);
@@ -3466,14 +3594,22 @@ export async function startProxy(opts = {}) {
3466
3594
  },
3467
3595
  })
3468
3596
  : null;
3469
- // Before this hook a codex request left no trace: nothing in
3470
- // /analytics, nothing in the request log, no per-account count.
3471
- // The dock (and anyone reading /analytics) saw a proxy that
3472
- // served GPT all day and reported zero of it. A decline (the
3473
- // request handed to the Claude pool) reports nothing here; the
3474
- // Claude path records what it then serves.
3597
+ // Reporting is one function for BOTH codex shapes below: the Responses
3598
+ // passthrough and the translated Messages path record the same row, so a
3599
+ // GPT request looks the same in /analytics whichever shape asked for it.
3600
+ //
3601
+ // Before this hook a codex request left no trace: nothing in /analytics,
3602
+ // nothing in the request log, no per-account count. The dock (and anyone
3603
+ // reading /analytics) saw a proxy that served GPT all day and reported
3604
+ // zero of it. A decline (the request handed to the Claude pool) reports
3605
+ // nothing here; the Claude path records what it then serves.
3475
3606
  const codexOnDone = (o) => {
3476
3607
  codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
3608
+ // A seat that actually SERVED is not rate-limited. Keyed on a 2xx,
3609
+ // never on forwardToCodex returning true — that means "I wrote a
3610
+ // response", which is equally true when it wrote the upstream 429.
3611
+ if (o.status >= 200 && o.status < 300)
3612
+ clearCodexDecline(o.alias);
3477
3613
  analytics.record({
3478
3614
  timestamp: Date.now(),
3479
3615
  consumer,
@@ -3502,24 +3638,83 @@ export async function startProxy(opts = {}) {
3502
3638
  cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
3503
3639
  }, consumer));
3504
3640
  };
3505
- // A Responses client on a ChatGPT-subscription model: the backend
3506
- // speaks that shape natively, so the body goes through as written
3507
- // (model resolved) and the SSE comes back untouched no round
3508
- // trip through the Messages shape, which cannot carry the newest
3509
- // Codex CLI request features. Answers on the raw response: these
3510
- // bytes are already in the client's shape.
3511
- const served = codexAvailable && (isResponses && responsesBodyRaw
3512
- ? await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone)
3513
- : await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer, codexOnDone,
3514
- // Cool codex on a rate limit only — a 5xx or an unreachable backend
3515
- // is an outage, and parking a provider for that would keep it out
3516
- // of the chain while it was already coming back.
3517
- (d) => { if (d.status === 429)
3518
- providerCooldowns.note('codex', d.retryAfterMs); },
3519
- // dario#1260 the effort named by the model-name suffix stripped
3520
- // above. Undefined for every request that did not name one, which
3521
- // leaves the outbound body exactly as it was.
3522
- effortForCodex(requestEffort), codexGuard));
3641
+ // Mid-flight seat failover. A 429 lands BEFORE any body is written
3642
+ // forwardToCodex only returns false on the decline path so the same
3643
+ // request can be handed to a healthy peer instead of failing. Without
3644
+ // this the pool only helps the request AFTER the one that discovered the
3645
+ // limit; the discovering request still failed, every window rollover.
3646
+ //
3647
+ // `deferOnUnavailable` is widened to `canDefer || a peer exists`: without
3648
+ // that, a decline with no Claude fallback configured writes the 429 to the
3649
+ // client and returns true, and there is nothing left to retry onto.
3650
+ //
3651
+ // Terminates by construction: every pass adds a seat to `codexTried`, and
3652
+ // selectCodexAccountExcluding never returns a seat already in it.
3653
+ let served = false;
3654
+ if (codexAvailable) {
3655
+ const codexTried = new Set();
3656
+ let codexSeat = codexCreds;
3657
+ while (codexSeat) {
3658
+ codexTried.add(codexSeat.alias);
3659
+ // Resolved BEFORE the attempt: it decides whether this attempt may
3660
+ // defer, and becomes the seat to retry on if it declines.
3661
+ // A peer that demonstrably does not list this model cannot serve it;
3662
+ // trying it would trade a 429 for a 400. peek is the cached read, so
3663
+ // this never costs an upstream call — an unknown list still gets a try.
3664
+ //
3665
+ // Scanning rather than testing one candidate: with mixed model
3666
+ // availability across seats, the alphabetically-next peer may be the
3667
+ // one that cannot serve this model while a later one can. Stopping at
3668
+ // the first incompatible candidate left `codexPeer` null and abandoned
3669
+ // a usable seat — with no Claude fallback the declining seat's 429 went
3670
+ // straight to the client (caught in review of #1288). `peerTried` is
3671
+ // seeded from `codexTried` and grows every pass, so this terminates.
3672
+ let codexPeer = null;
3673
+ const peerTried = new Set(codexTried);
3674
+ for (;;) {
3675
+ const candidate = await selectCodexAccountExcluding(peerTried).catch(() => null);
3676
+ if (!candidate)
3677
+ break;
3678
+ const peerSlugs = rawModel ? peekCodexModelSlugs(candidate.alias) : null;
3679
+ if (!peerSlugs || isCodexModel(rawModel, peerSlugs)) {
3680
+ codexPeer = candidate;
3681
+ break;
3682
+ }
3683
+ peerTried.add(candidate.alias);
3684
+ }
3685
+ // A Responses client on a ChatGPT-subscription model: the backend speaks
3686
+ // that shape natively, so the body goes through as written (model
3687
+ // resolved) and the SSE comes back untouched — no round trip through the
3688
+ // Messages shape, which cannot carry the newest Codex CLI request
3689
+ // features. Answers on the raw response: these bytes are already in the
3690
+ // client's shape.
3691
+ //
3692
+ // It sits INSIDE the retry loop, on the same seat sequence and the same
3693
+ // defer condition as the translated path. Outside it, a 429 on this shape
3694
+ // cooled nothing: selection handed the same limited seat back on every
3695
+ // following request and a healthy peer was never reached — the single-seat
3696
+ // outage this change exists to remove, surviving on the one shape Codex
3697
+ // CLI actually speaks (caught in review of #1288).
3698
+ if (isResponses && responsesBodyRaw) {
3699
+ served = await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone, codexOnDecline, canDefer || codexPeer !== null);
3700
+ }
3701
+ else {
3702
+ served = await forwardToCodex(req, res, body, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer || codexPeer !== null, codexOnDone, codexOnDecline,
3703
+ // dario#1260 — the effort named by the model-name suffix stripped
3704
+ // above. Undefined for every request that did not name one, which
3705
+ // leaves the outbound body exactly as it was.
3706
+ effortForCodex(requestEffort), codexGuard);
3707
+ }
3708
+ if (served || !codexPeer)
3709
+ break;
3710
+ console.log(`[dario] codex seat ${codexSeat.alias} declined — retrying this request on ${codexPeer.alias}`);
3711
+ // The conversation follows the request. Its binding still names
3712
+ // the seat that just declined; leaving it there would send the
3713
+ // next turn back to a cooling seat and re-pick from scratch.
3714
+ rebindCodexSticky(codexStickyKey, codexPeer.alias);
3715
+ codexSeat = await getFreshCodexAccount(codexPeer).catch(() => codexPeer);
3716
+ }
3717
+ }
3523
3718
  if (served) {
3524
3719
  // A provider that just served is not rate-limited.
3525
3720
  providerCooldowns.clear('codex');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.4.0",
3
+ "version": "6.5.0",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {