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.
- package/CHANGELOG.md +41 -0
- package/README.ko.md +24 -16
- package/README.md +26 -17
- package/dist/app/package.json +4 -0
- package/dist/cli/src/codex.js +26 -2
- package/dist/cli/src/index.js +14 -7
- package/dist/router/src/admin.js +74 -4
- package/dist/router/src/index.js +9 -2
- package/dist/router/src/ingress/server.js +77 -3
- package/dist/router/src/locked-file.js +80 -0
- package/dist/router/src/providers/anthropic-accounts.js +9 -74
- package/dist/router/src/providers/chatgpt/accounts.js +438 -0
- package/dist/router/src/providers/chatgpt/auth.js +43 -87
- package/dist/router/src/providers/chatgpt/index.js +483 -84
- package/dist/router/src/proxy.js +36 -6
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +128 -5
- package/dist/ui/i18n.js +4 -4
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +54 -3
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
188
|
+
accounts;
|
|
189
|
+
/** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
|
|
190
|
+
pool;
|
|
131
191
|
log;
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
410
|
+
this.rateLimitsInFlight = this.fetchAllRateLimits().finally(() => {
|
|
267
411
|
this.rateLimitsInFlight = null;
|
|
268
412
|
});
|
|
269
413
|
return this.rateLimitsInFlight;
|
|
270
414
|
}
|
|
271
|
-
async
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
this.log.warn(`chatgpt ${this.name}: rate-limit fetch skipped:
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 (
|
|
863
|
+
if (sent.kind === "aborted")
|
|
475
864
|
return { status: 0, bytes: 0, note: "client closed" };
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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.
|
|
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)
|