clauderipple 0.3.1 → 0.4.1

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.
@@ -2,7 +2,8 @@
2
2
  // Codex backend (OpenAI Responses over SSE) and streaming the translated answer back.
3
3
  import crypto from "node:crypto";
4
4
  import http from "node:http";
5
- import { CredentialStore } from "./auth.js";
5
+ import { CredentialPool, retryAfterMs } from "../../pool.js";
6
+ import { ChatGptAccountPool } from "./accounts.js";
6
7
  import { SseParser } from "./sse.js";
7
8
  import { fetchWithRetry } from "../retry.js";
8
9
  import { looksLikeAuth } from "../openai/index.js";
@@ -124,20 +125,163 @@ export function rateLimitsFromUsage(body) {
124
125
  at: Date.now(),
125
126
  };
126
127
  }
128
+ /**
129
+ * The request headers of Codex's that the backend reads, and nothing else: its protocol and
130
+ * session metadata. The caller's credential is not among them — the account decides that.
131
+ */
132
+ const CODEX_FORWARD_HEADERS = [
133
+ "content-type", "content-encoding", "accept", "openai-beta", "originator", "version", "user-agent",
134
+ "session_id", "session-id", "thread-id", "x-client-request-id",
135
+ "x-codex-beta-features", "x-codex-installation-id", "x-codex-parent-thread-id", "x-codex-turn-metadata",
136
+ "x-codex-turn-state", "x-codex-window-id", "x-oai-attestation", "x-openai-subagent", "x-responsesapi-include-timing-metrics",
137
+ ];
138
+ export function codexForwardHeaders(headers) {
139
+ const out = {};
140
+ for (const name of CODEX_FORWARD_HEADERS) {
141
+ const v = headers[name];
142
+ if (typeof v === "string")
143
+ out[name] = v;
144
+ else if (Array.isArray(v))
145
+ out[name] = v.join(", ");
146
+ }
147
+ out.originator ??= "codex_cli_rs";
148
+ return out;
149
+ }
150
+ function sendOpenAiError(res, status, type, message, note, resetsInSeconds) {
151
+ const body = JSON.stringify({ error: { type, message, ...(resetsInSeconds ? { resets_in_seconds: resetsInSeconds } : {}) } });
152
+ if (!res.headersSent)
153
+ res.writeHead(status, { "content-type": "application/json", ...(resetsInSeconds ? { "retry-after": String(resetsInSeconds) } : {}) }).end(body);
154
+ return { status, bytes: Buffer.byteLength(body), note };
155
+ }
156
+ /** When a window is back, in ms from now: its own countdown, else its reset time (epoch seconds). */
157
+ function windowResetMs(w, now) {
158
+ if (typeof w.reset_after_seconds === "number" && w.reset_after_seconds >= 0)
159
+ return w.reset_after_seconds * 1000;
160
+ if (typeof w.reset_at === "number") {
161
+ // Epoch seconds as measured; a value already in milliseconds would otherwise park the account
162
+ // for decades (bounded to hours by the pool, still hours for nothing).
163
+ const at = w.reset_at > 1e12 ? w.reset_at : w.reset_at * 1000;
164
+ if (at > now)
165
+ return at - now;
166
+ }
167
+ return undefined;
168
+ }
169
+ /**
170
+ * How long an account is out, from a rate-limit snapshot: the latest reset among the windows it
171
+ * has used up, since it is usable only once every full window has reset. Undefined when no window
172
+ * is full — the account is not out, whatever else the snapshot says.
173
+ */
174
+ export function exhaustedForMs(snapshot, now = Date.now()) {
175
+ const limits = (snapshot?.rate_limits ?? null);
176
+ let out;
177
+ for (const w of [limits?.primary, limits?.secondary]) {
178
+ if (!w || typeof w.used_percent !== "number" || w.used_percent < 100)
179
+ continue;
180
+ const ms = windowResetMs(w, now) ?? 60_000;
181
+ out = Math.max(out ?? 0, ms);
182
+ }
183
+ return out;
184
+ }
127
185
  export class ChatGptAdapter {
128
186
  name;
129
187
  cfg;
130
- creds;
188
+ accounts;
189
+ /** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
190
+ pool;
131
191
  log;
132
- lastRateLimits = null;
133
- constructor(name, cfg, home, log) {
192
+ /** Latest quota per account (owner id), from response headers or `/wham/usage`. */
193
+ rateLimitsByAccount = new Map();
194
+ /** The account that answered last: the one whose quota the single-number readers see. */
195
+ activeOwner = null;
196
+ constructor(name, cfg, home, log, pool = new CredentialPool(), fetchImpl) {
134
197
  this.name = name;
135
198
  this.cfg = cfg;
136
199
  this.log = log;
137
- this.creds = new CredentialStore(home, cfg.auth ?? "auto");
200
+ this.pool = pool;
201
+ this.accounts = new ChatGptAccountPool({ home, mode: cfg.auth ?? "auto", log, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
202
+ }
203
+ /**
204
+ * The quota of the account in use, in the shape it always had. Readers that want one number
205
+ * (the tray, the health line, other tools reading `/api/status`) keep getting one; the
206
+ * per-account view is `accountStatus()`.
207
+ */
208
+ get lastRateLimits() {
209
+ const credentials = this.accounts.peekCredentials();
210
+ const active = credentials.find((c) => c.ownerId === this.activeOwner);
211
+ if (active && this.pool.hasUsable(this.name, [active]) && this.rateLimitsByAccount.has(active.ownerId))
212
+ return this.rateLimitsByAccount.get(active.ownerId);
213
+ // Otherwise the account the next turn would go to: a spent account's 100% is not what is left.
214
+ const next = credentials.find((c) => this.pool.hasUsable(this.name, [c]) && this.rateLimitsByAccount.has(c.ownerId))
215
+ ?? credentials.find((c) => this.rateLimitsByAccount.has(c.ownerId));
216
+ return next ? this.rateLimitsByAccount.get(next.ownerId) : null;
217
+ }
218
+ /** Record a snapshot for an account; a full window takes it out of rotation until that window resets. */
219
+ noteRateLimits(credential, snapshot) {
220
+ if (!snapshot)
221
+ return;
222
+ this.rateLimitsByAccount.set(credential.ownerId, snapshot);
223
+ const outMs = exhaustedForMs(snapshot);
224
+ if (outMs !== undefined)
225
+ this.pool.penalise(this.name, credential.id, 429, outMs);
138
226
  }
139
227
  describeAuth() {
140
- return this.creds.describe();
228
+ const all = this.accounts.summaries();
229
+ const usable = this.accounts.peekCredentials().filter((c) => this.pool.hasUsable(this.name, [c])).length;
230
+ return `accounts=${all.length} usable=${usable} mode=${this.cfg.auth ?? "auto"}`;
231
+ }
232
+ /** Whether any account could answer now, for the proxy's choice between this provider and a fallback. */
233
+ hasUsable() {
234
+ return this.pool.hasUsable(this.name, this.accounts.peekCredentials());
235
+ }
236
+ signedIn() {
237
+ return this.accounts.signedIn();
238
+ }
239
+ /** Every account with its rotation state and last known quota. Metadata only — no token leaves. */
240
+ accountStatus() {
241
+ const credentials = this.accounts.peekCredentials();
242
+ const reports = new Map(this.pool.report(this.name, credentials).map((r) => [r.id, r]));
243
+ return this.accounts.summaries().map((summary) => {
244
+ const credential = credentials.find((c) => c.ownerId === summary.id);
245
+ const report = credential ? reports.get(credential.id) : undefined;
246
+ const state = summary.paused ? "paused" : !credential ? "needs-login" : report?.state ?? "ready";
247
+ return {
248
+ ...summary,
249
+ state,
250
+ ...(report?.cooldownSeconds ? { cooldownSeconds: report.cooldownSeconds } : {}),
251
+ quota: this.rateLimitsByAccount.get(summary.id) ?? null,
252
+ active: summary.id === this.activeOwner,
253
+ };
254
+ });
255
+ }
256
+ /** Put a cooling account back into rotation now (dashboard action). */
257
+ clearCooldown(ownerId) {
258
+ for (const c of this.accounts.peekCredentials())
259
+ if (c.ownerId === ownerId)
260
+ this.pool.clear(this.name, c.id);
261
+ }
262
+ /**
263
+ * An account for one turn: the conversation's own while it is healthy, else the first usable one.
264
+ * "none" when nothing is signed in; null when every account is cooling or already tried.
265
+ */
266
+ async pick(conversation, tried = new Set()) {
267
+ const all = await this.accounts.credentials();
268
+ if (all.length === 0)
269
+ return "none";
270
+ const rest = all.filter((c) => !tried.has(c.ownerId));
271
+ return this.pool.pick(this.name, rest, conversation);
272
+ }
273
+ /** For side calls (search, catalogue, quota) with no conversation: a usable account, else any. */
274
+ async anyCredential() {
275
+ const all = await this.accounts.credentials();
276
+ if (all.length === 0)
277
+ return new Error("no ChatGPT credentials: run `clauderipple login`, or sign in to the Codex CLI once");
278
+ return this.pool.pick(this.name, all) ?? all[0];
279
+ }
280
+ /** The soonest any account is back, for the message when all of them are out. */
281
+ soonestBackMs() {
282
+ const reports = this.pool.report(this.name, this.accounts.peekCredentials());
283
+ const cooling = reports.filter((r) => r.state === "cooling" && r.cooldownSeconds).map((r) => r.cooldownSeconds * 1000);
284
+ return cooling.length ? Math.min(...cooling) : undefined;
141
285
  }
142
286
  /**
143
287
  * Hosted web search through the same Codex backend and credential as ordinary ChatGPT turns.
@@ -156,7 +300,7 @@ export class ChatGptAdapter {
156
300
  // would violate the caller's request; fail visibly so the proxy can choose another backend.
157
301
  if (query.blockedDomains?.length)
158
302
  throw new Error("ChatGPT web search does not support blocked_domains");
159
- const tokens = await this.creds.get();
303
+ const tokens = await this.anyCredential();
160
304
  if (tokens instanceof Error)
161
305
  throw tokens;
162
306
  const id = crypto.randomUUID();
@@ -199,13 +343,11 @@ export class ChatGptAdapter {
199
343
  body: JSON.stringify(body),
200
344
  signal: requestSignal,
201
345
  });
202
- const rateLimits = rateLimitsFromHeaders(res.headers);
203
- if (rateLimits)
204
- this.lastRateLimits = rateLimits;
346
+ this.noteRateLimits(tokens, rateLimitsFromHeaders(res.headers));
205
347
  if (!res.ok || !res.body) {
206
348
  const text = await res.text().catch(() => "");
207
349
  if (res.status === 401)
208
- this.creds.invalidate();
350
+ void this.accounts.forceRefresh(tokens.ownerId);
209
351
  throw new Error(`ChatGPT web search: HTTP ${res.status}${text ? ` ${redactErrorText(text, [tokens.accessToken], 200)}` : ""}`);
210
352
  }
211
353
  const parser = new SseParser();
@@ -258,22 +400,28 @@ export class ChatGptAdapter {
258
400
  * Ask the backend for the current quota instead of waiting for a request to carry it in the
259
401
  * response headers. Without this, `/api/status` shows the last time GPT traffic flowed — 11
260
402
  * hours stale in one measurement (2026-09-20) — and the product's GPT budget read is wrong.
261
- * Never throws and never clears a good snapshot; returns the new one, or null on failure.
403
+ * Every account is asked, so the dashboard can show each one and an account that is already
404
+ * spent leaves rotation before a turn finds out the hard way. Never throws and never clears a
405
+ * good snapshot; returns the active account's new one, or null when none could be read.
262
406
  */
263
407
  fetchRateLimits() {
264
408
  if (this.rateLimitsInFlight)
265
409
  return this.rateLimitsInFlight;
266
- this.rateLimitsInFlight = this.fetchRateLimitsOnce().finally(() => {
410
+ this.rateLimitsInFlight = this.fetchAllRateLimits().finally(() => {
267
411
  this.rateLimitsInFlight = null;
268
412
  });
269
413
  return this.rateLimitsInFlight;
270
414
  }
271
- async fetchRateLimitsOnce() {
272
- const tokens = await this.creds.get();
273
- if (tokens instanceof Error) {
274
- this.log.warn(`chatgpt ${this.name}: rate-limit fetch skipped: ${tokens.message}`);
415
+ async fetchAllRateLimits() {
416
+ const all = await this.accounts.credentials();
417
+ if (all.length === 0) {
418
+ this.log.warn(`chatgpt ${this.name}: rate-limit fetch skipped: no ChatGPT credentials`);
275
419
  return null;
276
420
  }
421
+ const results = await Promise.all(all.map((c) => this.fetchRateLimitsOnce(c)));
422
+ return results.some(Boolean) ? this.lastRateLimits : null;
423
+ }
424
+ async fetchRateLimitsOnce(tokens, replayed = false) {
277
425
  const ac = new AbortController();
278
426
  const timer = setTimeout(() => ac.abort(), USAGE_TIMEOUT_MS);
279
427
  let res;
@@ -297,9 +445,14 @@ export class ChatGptAdapter {
297
445
  clearTimeout(timer);
298
446
  }
299
447
  if (!res.ok) {
300
- if (res.status === 401)
301
- this.creds.invalidate();
302
- this.log.warn(`chatgpt ${this.name}: rate-limit fetch HTTP ${res.status}`);
448
+ // A bare 401 here is usually a token that went stale early: one refresh and one replay, and
449
+ // no more — asking again and again with a dead grant is the loop to avoid.
450
+ if (res.status === 401 && !replayed && await this.accounts.forceRefresh(tokens.ownerId)) {
451
+ const fresh = this.accounts.peekCredentials().find((c) => c.ownerId === tokens.ownerId);
452
+ if (fresh)
453
+ return this.fetchRateLimitsOnce(fresh, true);
454
+ }
455
+ this.log.warn(`chatgpt ${this.name}: rate-limit fetch HTTP ${res.status} (account ${tokens.ownerId.slice(0, 8)})`);
303
456
  return null;
304
457
  }
305
458
  const parsed = rateLimitsFromUsage(await res.json().catch(() => null));
@@ -307,7 +460,7 @@ export class ChatGptAdapter {
307
460
  this.log.warn(`chatgpt ${this.name}: rate-limit fetch returned no primary window`);
308
461
  return null;
309
462
  }
310
- this.lastRateLimits = parsed;
463
+ this.noteRateLimits(tokens, parsed);
311
464
  return parsed;
312
465
  }
313
466
  /** One in-flight catalogue lookup shared by every caller, and the last good list for an hour. */
@@ -332,7 +485,7 @@ export class ChatGptAdapter {
332
485
  return this.modelsInFlight;
333
486
  }
334
487
  async fetchModelsOnce() {
335
- const tokens = await this.creds.get();
488
+ const tokens = await this.anyCredential();
336
489
  if (tokens instanceof Error) {
337
490
  this.log.warn(`chatgpt ${this.name}: model-catalog fetch skipped: ${tokens.message}`);
338
491
  return null;
@@ -362,7 +515,7 @@ export class ChatGptAdapter {
362
515
  if (!res.ok) {
363
516
  // The status only: the body can echo the request, and the token rides on it.
364
517
  if (res.status === 401)
365
- this.creds.invalidate();
518
+ void this.accounts.forceRefresh(tokens.ownerId);
366
519
  this.log.warn(`chatgpt ${this.name}: model-catalog fetch HTTP ${res.status}`);
367
520
  return null;
368
521
  }
@@ -401,7 +554,7 @@ export class ChatGptAdapter {
401
554
  const dir = path.join(homeDir(), "debug");
402
555
  fs.mkdirSync(dir, { recursive: true });
403
556
  const file = path.join(dir, `upstream-${new Date().toISOString().replace(/[:.]/g, "-")}-${status}.json`);
404
- fs.writeFileSync(file, JSON.stringify({ status, upstream: upstreamText, request: upstreamReq, anthropic }, null, 1));
557
+ fs.writeFileSync(file, JSON.stringify({ status, upstream: upstreamText, request: upstreamReq, anthropic }, null, 1), { mode: 0o600 });
405
558
  const files = fs.readdirSync(dir).filter((f) => f.startsWith("upstream-")).sort();
406
559
  for (const f of files.slice(0, Math.max(0, files.length - 60)))
407
560
  fs.rmSync(path.join(dir, f), { force: true });
@@ -410,6 +563,250 @@ export class ChatGptAdapter {
410
563
  this.log.warn(`chatgpt ${this.name}: debug dump failed: ${e.message}`);
411
564
  }
412
565
  }
566
+ /**
567
+ * Send one turn, moving to the next account while nothing has reached the client:
568
+ *
569
+ * - 401, or a 403 that reads as a credential refusal: one refresh of that account and a replay;
570
+ * refused again, the account is quarantined (and marked for sign-in when it is ours).
571
+ * - 429 and 402: the account rests until its window resets, from `retry-after` or the
572
+ * `x-codex-*` reset the backend reports, and the next account takes the turn.
573
+ * - Any other 403, 5xx, or no connection at all: a short rest, and the next account.
574
+ * - Anything else is the request's fault; another account would refuse it the same way.
575
+ *
576
+ * Each account is tried at most once per turn (a refreshed token is the same account). The
577
+ * caller supplies everything but the credential, and turns a failure into its client's wire.
578
+ */
579
+ async sendToAnAccount(spec) {
580
+ const tried = new Set();
581
+ const replayed = new Set();
582
+ const refreshed = new Set();
583
+ let last = null;
584
+ let lastThrown = null;
585
+ for (;;) {
586
+ const credential = await this.pick(spec.conversation, tried);
587
+ if (credential === "none")
588
+ return { kind: "no-account" };
589
+ if (credential === null) {
590
+ if (last)
591
+ return last;
592
+ if (lastThrown)
593
+ return { kind: "unreachable", error: lastThrown };
594
+ // Every account was already resting when the turn arrived.
595
+ const backMs = this.soonestBackMs();
596
+ return { kind: "all-resting", ...(backMs ? { backMs } : {}) };
597
+ }
598
+ const upstreamHeaders = { ...spec.headers(credential), ...credential.headers };
599
+ let upstream;
600
+ try {
601
+ // Retried here on the same account, before any status or byte reaches the client, so a
602
+ // relay's hiccup is absorbed inside the turn instead of arriving as an error to retry by hand.
603
+ upstream = await fetchWithRetry(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${spec.path}`, {
604
+ method: spec.method ?? "POST",
605
+ headers: upstreamHeaders,
606
+ ...(spec.body !== undefined ? { body: spec.body } : {}),
607
+ signal: spec.signal,
608
+ }, { log: (line) => this.log.info(`chatgpt ${this.name}: ${line}`) });
609
+ }
610
+ catch (e) {
611
+ if (spec.signal.aborted)
612
+ return { kind: "aborted" };
613
+ this.pool.penalise(this.name, credential.id, 0);
614
+ tried.add(credential.ownerId);
615
+ lastThrown = e;
616
+ this.log.info(`chatgpt ${this.name}: account ${credential.ownerId.slice(0, 8)} unreachable (${e.message}); trying another`);
617
+ continue;
618
+ }
619
+ this.noteRateLimits(credential, rateLimitsFromHeaders(upstream.headers));
620
+ if (upstream.ok && upstream.body) {
621
+ this.pool.succeed(this.name, credential.id);
622
+ this.activeOwner = credential.ownerId;
623
+ return { kind: "ok", upstream, credential };
624
+ }
625
+ const text = await upstream.text().catch(() => "");
626
+ // The workspace id is masked too: the dashboard never shows it, and an echoing error body must not either.
627
+ const safeText = redactErrorText(text, [...credentialHeaderValues(Object.entries(upstreamHeaders)), credential.accountId]);
628
+ const status = upstream.status;
629
+ this.log.warn(`chatgpt ${this.name}: account ${credential.ownerId.slice(0, 8)} answered ${status}: ${safeText.slice(0, 400)}`);
630
+ const credentialRefused = status === 401 || (status === 403 && looksLikeAuth(text));
631
+ if (credentialRefused) {
632
+ if (!replayed.has(credential.ownerId)) {
633
+ replayed.add(credential.ownerId);
634
+ if (await this.accounts.forceRefresh(credential.ownerId)) {
635
+ refreshed.add(credential.ownerId);
636
+ this.log.info(`chatgpt ${this.name}: account ${credential.ownerId.slice(0, 8)} refreshed after ${status}; replaying`);
637
+ continue;
638
+ }
639
+ }
640
+ else if (refreshed.has(credential.ownerId) && status === 401) {
641
+ // A token minted a moment ago and refused with a 401 anyway: the account itself is refused.
642
+ // Only a 401 says that — a 403 that merely mentions a token is often about the request, and
643
+ // would otherwise sign every account out in one turn. A refresh that failed to reach
644
+ // OpenAI proves nothing either way and leaves the account alone.
645
+ this.accounts.reject(credential);
646
+ }
647
+ }
648
+ const headerRecord = Object.fromEntries(upstream.headers.entries());
649
+ const snapshot = rateLimitsFromHeaders(upstream.headers);
650
+ const retryHeader = headerRecord["retry-after"] ? retryAfterMs({ "retry-after": headerRecord["retry-after"] }) : undefined;
651
+ const waitMs = retryHeader ?? exhaustedForMs(snapshot) ?? retryAfterMs(headerRecord);
652
+ // A refusal here is a rest, never a pool quarantine: "sign in again" is the store's to say
653
+ // (needsReauth, above), and a quarantine would outlive the new token a sign-in brings.
654
+ const verdict = this.pool.penalise(this.name, credential.id, credentialRefused ? 403 : status, waitMs, text);
655
+ last = { kind: "refused", status, text: safeText, headers: upstream.headers, ...(status === 429 && waitMs ? { retryAfterSeconds: Math.ceil(waitMs / 1000) } : {}) };
656
+ if (!verdict.retryable)
657
+ return last;
658
+ tried.add(credential.ownerId);
659
+ }
660
+ }
661
+ /** Which account issued each turn-state token Codex echoes back, so a moved conversation drops a foreign one. */
662
+ turnStateIssuer = new Map();
663
+ /**
664
+ * Codex's own ChatGPT traffic, passed through unchanged except for the account: Codex already
665
+ * speaks the backend's wire (Responses, `store: false`, its session headers), so the body and its
666
+ * protocol headers go as they are and only `authorization` / `chatgpt-account-id` are chosen here.
667
+ * That is what lets Codex keep working on account 2 when account 1 runs out, without signing out.
668
+ *
669
+ * With no account of ours signed in, or every one of them resting, the caller's own login — the
670
+ * one Codex sent — is used as it is, so pointing Codex here never leaves it worse off.
671
+ */
672
+ async passthrough(req, res, subPath, body, conversation,
673
+ /** `onCompleted` fires at `response.completed`: Codex hangs up right after it, and that is a finished turn. */
674
+ opts = {}) {
675
+ const ac = new AbortController();
676
+ const onClose = () => ac.abort();
677
+ res.on("close", onClose);
678
+ const forwarded = codexForwardHeaders(req.headers);
679
+ // Codex's body goes out as it came, compressed; a rewritten one does not carry the old encoding.
680
+ if (!opts.bodyEncoded || !body)
681
+ delete forwarded["content-encoding"];
682
+ const clientTurnState = forwarded["x-codex-turn-state"];
683
+ const method = req.method ?? "POST";
684
+ const sent = await this.sendToAnAccount({
685
+ conversation,
686
+ path: `/codex${subPath}`,
687
+ method,
688
+ ...(body ? { body } : {}),
689
+ signal: ac.signal,
690
+ headers: (credential) => {
691
+ const out = { ...forwarded };
692
+ // A token another account issued means nothing to this one (a conversation that moved).
693
+ const issuer = clientTurnState ? this.turnStateIssuer.get(clientTurnState) : undefined;
694
+ if (clientTurnState && issuer && issuer !== credential.ownerId)
695
+ delete out["x-codex-turn-state"];
696
+ return out;
697
+ },
698
+ });
699
+ let upstream;
700
+ let owner = null;
701
+ if (sent.kind === "ok") {
702
+ upstream = sent.upstream;
703
+ owner = sent.credential.ownerId;
704
+ }
705
+ else if (sent.kind === "aborted") {
706
+ res.off("close", onClose);
707
+ return { status: 0, bytes: 0, note: "client closed" };
708
+ }
709
+ else {
710
+ const callerAuth = typeof req.headers.authorization === "string" && typeof req.headers["chatgpt-account-id"] === "string";
711
+ // Out of accounts — none signed in, all resting, or the last one just ran out on this turn.
712
+ const outOfAccounts = sent.kind === "no-account" || sent.kind === "all-resting" || (sent.kind === "refused" && (sent.status === 429 || sent.status === 402));
713
+ if (outOfAccounts && callerAuth) {
714
+ // Codex's own login, exactly as it sent it. Not refreshed or stored: it is Codex's. A turn
715
+ // token one of our accounts issued means nothing to it.
716
+ const callerHeaders = { ...forwarded };
717
+ if (clientTurnState && this.turnStateIssuer.has(clientTurnState))
718
+ delete callerHeaders["x-codex-turn-state"];
719
+ try {
720
+ upstream = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex${subPath}`, {
721
+ method,
722
+ headers: { ...callerHeaders, authorization: String(req.headers.authorization), "chatgpt-account-id": String(req.headers["chatgpt-account-id"]) },
723
+ ...(body ? { body } : {}),
724
+ signal: ac.signal,
725
+ });
726
+ }
727
+ catch (e) {
728
+ res.off("close", onClose);
729
+ if (ac.signal.aborted)
730
+ return { status: 0, bytes: 0, note: "client closed" };
731
+ return sendOpenAiError(res, 502, "api_error", `ChatGPT backend unreachable: ${e.message}`, "caller login unreachable");
732
+ }
733
+ }
734
+ else {
735
+ res.off("close", onClose);
736
+ if (sent.kind === "unreachable")
737
+ return sendOpenAiError(res, 502, "api_error", `ChatGPT backend unreachable: ${sent.error.message}`, "unreachable");
738
+ if (sent.kind === "no-account")
739
+ return sendOpenAiError(res, 401, "invalid_request_error", "no ChatGPT account: run `clauderipple login`", "no credentials");
740
+ if (sent.kind === "all-resting") {
741
+ // The shape the backend itself uses, so Codex shows its own "usage limit" message and wait.
742
+ const seconds = sent.backMs ? Math.ceil(sent.backMs / 1000) : undefined;
743
+ return sendOpenAiError(res, 429, "usage_limit_reached", "Every ChatGPT account signed in to ClaudeRipple has reached its usage limit.", "all accounts resting", seconds);
744
+ }
745
+ // The backend's own refusal, as Codex would have seen it without us (secrets masked).
746
+ const text = sent.text || JSON.stringify({ error: { message: `HTTP ${sent.status}` } });
747
+ res.writeHead(sent.status, { "content-type": sent.headers.get("content-type") ?? "application/json", ...(sent.retryAfterSeconds ? { "retry-after": String(sent.retryAfterSeconds) } : {}) }).end(text);
748
+ return { status: sent.status, bytes: Buffer.byteLength(text), note: `upstream ${sent.status}` };
749
+ }
750
+ }
751
+ const issued = upstream.headers.get("x-codex-turn-state");
752
+ if (issued && owner) {
753
+ this.turnStateIssuer.set(issued, owner);
754
+ if (this.turnStateIssuer.size > 1000)
755
+ this.turnStateIssuer.delete(this.turnStateIssuer.keys().next().value);
756
+ }
757
+ // Relay status, the protocol headers Codex reads (rate limits, turn state, request ids) and the
758
+ // body byte for byte. The body is read alongside for the request log's token counts.
759
+ const outHeaders = {};
760
+ for (const [k, v] of upstream.headers) {
761
+ if (k === "content-type" || k === "cache-control" || k === "retry-after" || k.startsWith("x-codex-") || k.startsWith("openai-") || k === "x-request-id" || k === "x-oai-request-id")
762
+ outHeaders[k] = v;
763
+ }
764
+ res.writeHead(upstream.status, outHeaders);
765
+ const parser = new SseParser();
766
+ const decoder = new TextDecoder();
767
+ let bytes = 0;
768
+ let usage;
769
+ // The backend answers Codex's streaming turns with no content-type at all (measured 2026-09-24),
770
+ // so anything not declared JSON is read as the event stream it is.
771
+ const isSse = !/json/i.test(upstream.headers.get("content-type") ?? "");
772
+ try {
773
+ if (upstream.body) {
774
+ const reader = upstream.body.getReader();
775
+ for (;;) {
776
+ const { done, value } = await reader.read();
777
+ if (done)
778
+ break;
779
+ bytes += value.length;
780
+ if (!res.writableEnded && !res.destroyed)
781
+ res.write(value);
782
+ if (isSse) {
783
+ for (const ev of parser.feed(decoder.decode(value, { stream: true }))) {
784
+ if (ev.type !== "response.completed")
785
+ continue;
786
+ const u = ev.response?.usage;
787
+ if (u) {
788
+ const cached = u.input_tokens_details?.cached_tokens ?? 0;
789
+ usage = { input: (u.input_tokens ?? 0) - cached, cached, output: u.output_tokens ?? 0 };
790
+ }
791
+ opts.onCompleted?.({ status: upstream.status, bytes, ...(usage ? { usage } : {}), note: `codex passthrough account=${owner ? owner.slice(0, 8) : "caller"}` });
792
+ }
793
+ }
794
+ }
795
+ }
796
+ }
797
+ catch (e) {
798
+ if (!ac.signal.aborted)
799
+ this.log.warn(`chatgpt ${this.name}: codex passthrough interrupted: ${e.message}`);
800
+ res.destroy();
801
+ return { status: upstream.status, bytes, note: "stream interrupted" };
802
+ }
803
+ finally {
804
+ res.off("close", onClose);
805
+ }
806
+ if (!res.writableEnded)
807
+ res.end();
808
+ return { status: upstream.status, bytes, ...(usage ? { usage } : {}), note: `codex passthrough account=${owner ? owner.slice(0, 8) : "caller"}` };
809
+ }
413
810
  /** Handle a fully-read Messages request. `model`/`effort` already resolved by routing. */
414
811
  async handle(req, res, path, json, model, effort) {
415
812
  if (path.startsWith("/v1/messages/count_tokens")) {
@@ -417,12 +814,6 @@ export class ChatGptAdapter {
417
814
  res.writeHead(200, { "content-type": "application/json", "content-length": String(body.length) }).end(body);
418
815
  return { status: 200, bytes: body.length, note: "estimated" };
419
816
  }
420
- const tokens = await this.creds.get();
421
- if (tokens instanceof Error) {
422
- const e = anthropicError(401, "authentication_error", tokens.message);
423
- res.writeHead(e.status, { "content-type": "application/json" }).end(e.body);
424
- return { status: e.status, bytes: e.body.length, note: "no credentials" };
425
- }
426
817
  // Dropping a tool the model was meant to have is worth a line: the alternative to this drop is
427
818
  // an empty answer with nothing logged anywhere, which is what made it expensive to find.
428
819
  const serverTools = serverToolNames(json.tools);
@@ -441,65 +832,73 @@ export class ChatGptAdapter {
441
832
  const ac = new AbortController();
442
833
  const onClose = () => ac.abort();
443
834
  res.on("close", onClose);
444
- const turnState = this.turnStateByKey.get(cacheKey);
445
- const upstreamHeaders = {
446
- "content-type": "application/json",
447
- accept: "text/event-stream",
448
- authorization: `Bearer ${tokens.accessToken}`,
449
- "chatgpt-account-id": tokens.accountId,
450
- "OpenAI-Beta": "responses=experimental",
451
- originator: "codex_cli_rs",
452
- // The conversation's identity, as the Codex CLI states it. This is what the backend keys
453
- // the prompt cache on since mid-September 2026 (see `conversationId` in translate.ts).
454
- "session-id": cacheKey,
455
- "thread-id": cacheKey,
456
- "x-client-request-id": cacheKey,
457
- "x-codex-window-id": `${cacheKey}:0`,
458
- ...(turnState ? { "x-codex-turn-state": turnState } : {}),
459
- };
460
- const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
461
- let upstream;
462
- try {
463
- // Retried here, before any status or byte reaches the client, so a relay's hiccup is absorbed
464
- // inside the turn instead of arriving as an error the user has to retry by hand.
465
- upstream = await fetchWithRetry(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
466
- method: "POST",
467
- headers: upstreamHeaders,
468
- body,
469
- signal: ac.signal,
470
- }, { log: (line) => this.log.info(`chatgpt ${this.name}: ${line}`) });
471
- }
472
- catch (e) {
835
+ // One account answers the turn. Which one is decided here, and a refusal before any byte has
836
+ // reached the client moves the same turn to the next account, so the client is answered on its
837
+ // first ask. The conversation stays on the account that answered: moving it costs the cache.
838
+ const sent = await this.sendToAnAccount({
839
+ conversation: cacheKey,
840
+ path: "/codex/responses",
841
+ body,
842
+ signal: ac.signal,
843
+ headers: (credential) => {
844
+ // Turn state is issued per account; another account's would be meaningless to the backend.
845
+ const turnState = this.turnStateByKey.get(`${credential.ownerId}\0${cacheKey}`);
846
+ return {
847
+ "content-type": "application/json",
848
+ accept: "text/event-stream",
849
+ "OpenAI-Beta": "responses=experimental",
850
+ originator: "codex_cli_rs",
851
+ // The conversation's identity, as the Codex CLI states it. This is what the backend keys
852
+ // the prompt cache on since mid-September 2026 (see `conversationId` in translate.ts).
853
+ "session-id": cacheKey,
854
+ "thread-id": cacheKey,
855
+ "x-client-request-id": cacheKey,
856
+ "x-codex-window-id": `${cacheKey}:0`,
857
+ ...(turnState ? { "x-codex-turn-state": turnState } : {}),
858
+ };
859
+ },
860
+ });
861
+ if (sent.kind !== "ok") {
473
862
  res.off("close", onClose);
474
- if (ac.signal.aborted)
863
+ if (sent.kind === "aborted")
475
864
  return { status: 0, bytes: 0, note: "client closed" };
476
- const err = anthropicError(502, "api_error", `ChatGPT backend unreachable: ${e.message}`);
477
- if (!res.headersSent)
478
- res.writeHead(err.status, { "content-type": "application/json" }).end(err.body);
479
- throw e; // let the proxy feed health with the connect error
480
- }
481
- const fromHeaders = rateLimitsFromHeaders(upstream.headers);
482
- if (fromHeaders)
483
- this.lastRateLimits = fromHeaders;
865
+ if (sent.kind === "unreachable") {
866
+ const err = anthropicError(502, "api_error", `ChatGPT backend unreachable: ${sent.error.message}`);
867
+ if (!res.headersSent)
868
+ res.writeHead(err.status, { "content-type": "application/json" }).end(err.body);
869
+ throw sent.error; // let the proxy feed health with the connect error
870
+ }
871
+ let err;
872
+ let retryAfterSeconds;
873
+ let note;
874
+ if (sent.kind === "no-account") {
875
+ err = anthropicError(401, "authentication_error", "no ChatGPT credentials: run `clauderipple login`, or sign in to the Codex CLI once");
876
+ note = "no credentials";
877
+ }
878
+ else if (sent.kind === "all-resting") {
879
+ const when = sent.backMs ? ` The first is usable again at ${new Date(Date.now() + sent.backMs).toLocaleTimeString()}.` : "";
880
+ err = anthropicError(429, "rate_limit_error", `ChatGPT: every signed-in account is at its usage limit.${when}`);
881
+ if (sent.backMs)
882
+ retryAfterSeconds = Math.ceil(sent.backMs / 1000);
883
+ note = "all accounts resting";
884
+ }
885
+ else {
886
+ err = mapHttpError(sent.status, sent.text);
887
+ retryAfterSeconds = sent.retryAfterSeconds;
888
+ note = `upstream ${sent.status}`;
889
+ if (this.cfg.debugDump)
890
+ this.dump(sent.status, json, upstreamReq, sent.text);
891
+ }
892
+ res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
893
+ return { status: err.status, bytes: err.body.length, note };
894
+ }
895
+ const { upstream, credential } = sent;
484
896
  const nextTurnState = upstream.headers.get("x-codex-turn-state");
485
897
  if (nextTurnState) {
486
- this.turnStateByKey.set(cacheKey, nextTurnState);
898
+ this.turnStateByKey.set(`${credential.ownerId}\0${cacheKey}`, nextTurnState);
487
899
  if (this.turnStateByKey.size > 500)
488
900
  this.turnStateByKey.delete(this.turnStateByKey.keys().next().value);
489
901
  }
490
- if (!upstream.ok || !upstream.body) {
491
- const text = await upstream.text().catch(() => "");
492
- const safeText = redactErrorText(text, upstreamSecrets);
493
- if (upstream.status === 401)
494
- this.creds.invalidate();
495
- const err = mapHttpError(upstream.status, safeText);
496
- this.log.warn(`chatgpt ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
497
- if (this.cfg.debugDump)
498
- this.dump(upstream.status, json, upstreamReq, safeText);
499
- res.off("close", onClose);
500
- res.writeHead(err.status, { "content-type": "application/json" }).end(err.body);
501
- return { status: err.status, bytes: err.body.length, note: `upstream ${upstream.status}` };
502
- }
503
902
  if (this.cfg.debugDump === "all")
504
903
  this.dump(upstream.status, json, upstreamReq, "");
505
904
  const wantStream = json.stream === true;
@@ -525,8 +924,8 @@ export class ChatGptAdapter {
525
924
  break;
526
925
  for (const ev of parser.feed(decoder.decode(value, { stream: true }))) {
527
926
  const outEvents = mapper.feed(ev);
528
- if (mapper.rateLimits)
529
- this.lastRateLimits = mapper.rateLimits;
927
+ if (mapper.rateLimits && mapper.rateLimits !== this.rateLimitsByAccount.get(credential.ownerId))
928
+ this.noteRateLimits(credential, mapper.rateLimits);
530
929
  this.rememberInput(cacheKey, mapper.usage);
531
930
  if (wantStream)
532
931
  for (const o of outEvents)