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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-09-24
4
+
5
+ ### Fixed
6
+
7
+ - **The tray starts from an npm install** (#8). After 0.3.1 it launched as
8
+ Electron but died at once with "exports is not defined in ES module scope": the
9
+ tray is CommonJS and the package root says `"type": "module"`. The tray now has
10
+ its own `package.json`. Checked by starting it from the packed package.
11
+
12
+ ## 0.4.0 — 2026-09-24
13
+
14
+ Several ChatGPT accounts, for Claude Desktop and for Codex.
15
+
16
+ ### Added
17
+
18
+ - **Several ChatGPT accounts.** `clauderipple login` (or **+ Add ChatGPT account**
19
+ in the dashboard) adds an account instead of replacing the one you had; the
20
+ sign-in asks who is signing in, so the browser's current ChatGPT session is not
21
+ silently reused. When an account hits its limit, the same request moves to the
22
+ next one before anything reaches the client, and the spent account rests until
23
+ the window the backend reports resets. A conversation stays on the account that
24
+ answered, to keep its prompt cache. A 401 gets one token refresh and a replay;
25
+ only a freshly minted token refused again asks for a new sign-in. Accounts live
26
+ in `~/.clauderipple/chatgpt-accounts.json` (0600); the old single login is moved
27
+ there on the first sign-in. The Codex CLI's own login still takes part, last.
28
+ - **Accounts tab for ChatGPT** in the dashboard: each account's 5-hour and weekly
29
+ usage, when a resting one is back, and pause/resume, rename, remove, use now and
30
+ sign in again.
31
+ - **Codex's GPT goes through the account pool.** `clauderipple codex on` now also
32
+ points Codex's built-in OpenAI provider at ClaudeRipple. Codex keeps its ChatGPT
33
+ sign-in; its GPT requests reach ChatGPT unchanged except for the account, so when
34
+ one account runs out Codex carries on with the next — no sign-out. With no account
35
+ added, or every one at its limit, Codex's own login is used. An `openai_base_url`
36
+ of your own is never overwritten; `clauderipple codex off` undoes it.
37
+
38
+ ### Changed
39
+
40
+ - `clauderipple logout` removes every ChatGPT account added with `login`.
41
+ - `/api/status` keeps `chatgpt.quota` (the account that answers next) and adds
42
+ `chatgpt.accounts`, one entry per account.
43
+
3
44
  ## 0.3.1 — 2026-09-24
4
45
 
5
46
  Fixes for three reports against 0.3.0.
package/README.ko.md CHANGED
@@ -123,21 +123,19 @@ x64 런타임은 에뮬레이션으로 확인했습니다.
123
123
  ClaudeRipple은 데스크톱 앱·터미널·Codex를 메뉴 막대 앱 하나로 다루고, 두 구독을 나란히 쓰며, Claude Code 하네스를
124
124
  그대로 둡니다. 스킬·훅·MCP 서버·`CLAUDE.md`·서브에이전트·claude.ai 커넥터가 전부 살아 있는 채로 두뇌만 바뀝니다.
125
125
 
126
- | | ClaudeRipple | opencodex / openclaude | claude-code-router | Claude Desktop 게이트웨이(3P) 모드 |
127
- |---|---|---|---|---|
128
- | 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ❌ ¹ | ❌ | ❌ (정의상 불가) |
129
- | claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ❌ | ❌ | ❌ |
130
- | Claude 구독과 GPT 구독 나란히 | ✅ | ❌ 전부 아니면 전무 | ❌ | ❌ |
131
- | Desktop 피커에 실제 모델 이름 | ✅ | ❌ | ❌ | 일부 |
132
- | 터미널 `claude` CLI | | ✅ | | |
133
- | Codex **앱**·Codex CLI → Claude | ✅ | ✅ | | ❌ |
134
- | 번역 프로바이더의 프롬프트 캐시 | **94~99 %** 실측 | 미측정 | 제각각 | 해당 없음 |
135
- | 서브에이전트를 실제 모델 이름으로 표시 | | | | |
136
- | 터미널 없이 쓰는 설정 GUI | ✅ | ❌ | ❌ | ❌ |
137
- | 런타임 내장, 서명·공증된 | ✅ | ❌ | ❌ | – |
138
-
139
- ¹ opencodex는 README에 Claude Desktop 데모를 걸어 두었지만, 저장소에도 문서 사이트에도 설치 절차가 없습니다
140
- (2026-09-16 확인). 데스크톱 앱으로 들어가는 공개된 경로는 공식 게이트웨이 설정, 즉 마지막 칸뿐입니다.
126
+ | | ClaudeRipple | claude-code-router | Claude Desktop 게이트웨이(3P) 모드 |
127
+ |---|---|---|---|
128
+ | 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ❌ | ❌ (정의상 불가) |
129
+ | claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ❌ | ❌ |
130
+ | Claude 구독과 GPT 구독 나란히 | ✅ | ❌ | ❌ |
131
+ | Desktop 피커에 실제 모델 이름 | ✅ | ❌ | 일부 |
132
+ | ChatGPT 계정 여러 개, 한도 차면 자동 전환 (Desktop·Codex) | ✅ | | |
133
+ | 터미널 `claude` CLI | ✅ | ✅ | |
134
+ | Codex **앱**·Codex CLI Claude | | | |
135
+ | 번역 프로바이더의 프롬프트 캐시 | **94~99 %** 실측 | 제각각 | 해당 없음 |
136
+ | 서브에이전트를 실제 모델 이름으로 표시 | ✅ | ❌ | ❌ |
137
+ | 터미널 없이 쓰는 설정 GUI | ✅ | ❌ | ❌ |
138
+ | 런타임 내장, 서명·공증된 앱 | ✅ | ❌ | – |
141
139
 
142
140
  데스크톱 앱의 "서드파티 추론" 설정은 앱 전체를 다른 모드로 바꿔 버립니다. claude.ai 채팅, Remote Control,
143
141
  클라우드 세션을 잃습니다. `ANTHROPIC_BASE_URL`을 바꿔치기하는 도구들은 데스크톱 앱에 아예 닿지 못합니다.
@@ -155,6 +153,10 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
155
153
  구독 계정을 추가하면, 대화마다 답한 계정에 고정됩니다. 응답이 시작되기 전 한도·인증 거부가 오면 같은 요청을
156
154
  다음 계정으로 넘깁니다. 이 Claude Desktop/Code 원형 경로는 번역을 거치는 Codex 입구와 별개이며, Codex 쪽은
157
155
  사용 가능한 로그인 하나만 골라 쓰고 계정을 자동 전환하지 않습니다.
156
+ - **ChatGPT 계정 여러 개.** `clauderipple login`(또는 대시보드의 **+ ChatGPT 계정 추가**)을 할 때마다 계정이
157
+ 늘어납니다. 한 계정이 한도에 걸리면 같은 요청을 다음 계정이 이어받고, 그 계정은 한도가 풀릴 때까지 쉽니다.
158
+ 대화는 답한 계정에 고정되어 캐시를 지킵니다. Claude Desktop Code 탭·서브에이전트와 **Codex 앱·CLI의 GPT**에
159
+ 모두 적용됩니다. Codex는 로그인을 그대로 두고, 로그아웃·재로그인 없이 다음 계정으로 넘어갑니다.
158
160
  - **Claude Code 하네스는 손대지 않습니다.** 스킬, 훅, MCP, `CLAUDE.md`, 서브에이전트, 플랜 모드, 휴대폰 Remote Control.
159
161
  아무것도 꺼지지 않습니다.
160
162
  - **구조적으로 올바르게.** 프롬프트 캐시 보존(Anthropic 캐시 브레이크포인트, 고정된 OpenAI 프리픽스), Claude Code의
@@ -299,6 +301,12 @@ clauderipple codex on # ~/.codex/config.toml에 "clauderipple" 프로바이
299
301
  codex --profile clauderipple -m claude-sonnet-5
300
302
  ```
301
303
 
304
+ `codex on`은 Codex의 기본 OpenAI 경로(`openai_base_url`)도 ClaudeRipple로 돌립니다. Codex는 ChatGPT 로그인을
305
+ 그대로 쓰고, GPT 요청은 내용 그대로 ChatGPT로 넘어가되 계정만 ClaudeRipple에 추가한 계정 중에서 골라 끼웁니다.
306
+ 그래서 계정을 여러 개 추가해 두면 Codex도 한도가 찬 계정에서 다음 계정으로 넘어갑니다. 추가한 계정이 없거나
307
+ 전부 한도에 걸려 있으면 Codex 자신의 로그인으로 보냅니다. 직접 넣어 둔 `openai_base_url`이 있으면 건드리지 않습니다.
308
+ `clauderipple codex off`로 되돌립니다.
309
+
302
310
  Claude 모델이 Codex 모델 목록에 이름 그대로 뜹니다(ClaudeRipple이 Codex 자체 카탈로그 옆에 모델 카탈로그를 씁니다).
303
311
  Claude는 Claude Code 로그인(실행 중인 Desktop 세션, 터미널 로그인, 또는 ClaudeRipple 자체 로그인 — **프로바이더 →
304
312
  Claude → Claude 구독 연결…**을 누르면 브라우저가 열리고 터미널은 필요 없습니다. `clauderipple claude-login`과 같습니다)이나
@@ -313,7 +321,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
313
321
 
314
322
  | 프로바이더 | 종류 | 인증 | 모델 목록 | 비고 |
315
323
  |---|---|---|---|---|
316
- | ChatGPT 구독 | Codex 백엔드 | 로그인(또는 Codex 로그인 재사용) | Terra, Sol, Luna, Astra | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
324
+ | ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | Terra, Sol, Luna, Astra | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
317
325
  | OpenRouter | Anthropic 호환 | API 키 | 400+, 자동 검색 | 모델별 추론 강도 지원을 API에서 읽음 |
318
326
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
319
327
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
package/README.md CHANGED
@@ -139,22 +139,19 @@ Codex from one menu-bar app, runs both subscriptions side by side, and keeps the
139
139
  Claude Code harness intact: your skills, hooks, MCP servers, `CLAUDE.md`, subagents
140
140
  and claude.ai connectors keep working while another model does the thinking.
141
141
 
142
- | | ClaudeRipple | opencodex / openclaude | claude-code-router | Claude Desktop gateway (3P) mode |
143
- |---|---|---|---|---|
144
- | Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ | ❌ ¹ | ❌ | ❌ by definition |
145
- | Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ | ❌ | ❌ | ❌ |
146
- | Claude and GPT subscriptions side by side | ✅ | ❌ all-or-nothing | ❌ | ❌ |
147
- | Real model names in the Desktop picker | ✅ | ❌ | ❌ | partial |
148
- | Terminal `claude` CLI | | ✅ | | |
149
- | Codex **app** and Codex CLI → Claude | ✅ | ✅ | | ❌ |
150
- | Prompt cache on translated providers | **94–99 %** measured | not measured | varies | n/a |
151
- | Subagents named by real model in the task panel | | | | |
152
- | Settings GUI, no terminal needed | | | ❌ | ❌ |
153
- | Signed, notarized app with its own runtime | ✅ | ❌ | ❌ | – |
154
-
155
- ¹ opencodex's README shows Claude Desktop in a demo but publishes no setup steps for it,
156
- in the repository or on its documentation site (checked 2026-09-16). The only public way
157
- into the desktop app is the official gateway setting — the last column.
142
+ | | ClaudeRipple | claude-code-router | Claude Desktop gateway (3P) mode |
143
+ |---|---|---|---|
144
+ | Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ | ❌ | ❌ by definition |
145
+ | Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ | ❌ | ❌ |
146
+ | Claude and GPT subscriptions side by side | ✅ | ❌ | ❌ |
147
+ | Real model names in the Desktop picker | ✅ | ❌ | partial |
148
+ | Several ChatGPT accounts, switching when one runs out (Desktop and Codex) | ✅ | | |
149
+ | Terminal `claude` CLI | ✅ | ✅ | |
150
+ | Codex **app** and Codex CLI Claude | | | |
151
+ | Prompt cache on translated providers | **94–99 %** measured | varies | n/a |
152
+ | Subagents named by real model in the task panel | | ❌ | ❌ |
153
+ | Settings GUI, no terminal needed | ✅ | ❌ | ❌ |
154
+ | Signed, notarized app with its own runtime | ✅ | ❌ | – |
158
155
 
159
156
  The desktop app's own "third-party inference" setting flips the whole app into another
160
157
  mode: you lose claude.ai chat, Remote Control and cloud sessions. Tools that replace
@@ -178,6 +175,12 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
178
175
  quota or authentication refusal moves that turn to the next account. This native
179
176
  Claude Desktop/Code path is separate from the translated Codex ingress, which picks
180
177
  one available login and does not rotate accounts.
178
+ - **Several ChatGPT accounts.** Each `clauderipple login` (or **+ Add ChatGPT account**
179
+ in the dashboard) adds one. When an account hits its limit, the next one takes over
180
+ the same request and the spent one rests until its window resets; a conversation
181
+ stays on the account that answered, to keep its cache. This covers the Claude Desktop
182
+ Code tab and subagents **and GPT in the Codex app and CLI** — Codex keeps its own
183
+ sign-in and moves to the next account without signing out.
181
184
  - **The full Claude Code harness, untouched.** Skills, hooks, MCP, `CLAUDE.md`,
182
185
  subagents, plan mode, Remote Control on your phone: nothing is turned off.
183
186
  - **Correct by construction.** Prompt caching preserved (Anthropic cache breakpoints
@@ -342,6 +345,12 @@ clauderipple codex on # adds a "clauderipple" provider to ~/.codex/config.to
342
345
  codex --profile clauderipple -m claude-sonnet-5
343
346
  ```
344
347
 
348
+ `codex on` also points Codex's built-in OpenAI provider at ClaudeRipple (`openai_base_url`).
349
+ Codex keeps its ChatGPT sign-in; its GPT requests go on to ChatGPT unchanged, except that the
350
+ account is chosen from the ones added to ClaudeRipple — so with several added, Codex too moves
351
+ from a spent account to the next. With none added, or all of them at their limit, Codex's own
352
+ login is used. An `openai_base_url` you set yourself is left alone. `clauderipple codex off` undoes it.
353
+
345
354
  Claude models show up in Codex's model list under their names (ClaudeRipple writes
346
355
  a model catalog next to Codex's own). Claude is reached through your Claude Code
347
356
  login (detected from the running Desktop session, the terminal login, or a
@@ -359,7 +368,7 @@ provider you configured is available the same way.
359
368
 
360
369
  | Provider | Kind | Auth | Model list | Notes |
361
370
  |---|---|---|---|---|
362
- | ChatGPT subscription | Codex backend | sign in (or reuse Codex login) | Terra, Sol, Luna, Astra | effort low…max (Luna: ultra), prompt cache 94–99 % |
371
+ | ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) | Terra, Sol, Luna, Astra | effort low…max (Luna: ultra), prompt cache 94–99 % |
363
372
  | OpenRouter | Anthropic-compatible | API key | 400+, discovered | per-model effort support read from the API |
364
373
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
365
374
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "commonjs"
4
+ }
@@ -10,6 +10,8 @@ const PROFILE_START = "# >>> ClaudeRipple Codex profile >>>";
10
10
  const PROFILE_END = "# <<< ClaudeRipple Codex profile <<<";
11
11
  const CATALOG_START = "# >>> ClaudeRipple Codex model catalog >>>";
12
12
  const CATALOG_END = "# <<< ClaudeRipple Codex model catalog <<<";
13
+ const OPENAI_START = "# >>> ClaudeRipple OpenAI route >>>";
14
+ const OPENAI_END = "# <<< ClaudeRipple OpenAI route <<<";
13
15
  export const CODEX_PROVIDER_MARKER = START;
14
16
  export function codexHome() {
15
17
  return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
@@ -114,6 +116,28 @@ function withCatalog(content, file) {
114
116
  return stripped;
115
117
  return `${catalogBlock(file)}\n\n${stripped}`.replace(/\n+$/, "\n");
116
118
  }
119
+ /**
120
+ * Codex's built-in `openai` provider pointed at our ingress (`openai_base_url`, a root key). Codex
121
+ * keeps its ChatGPT sign-in and sends GPT turns here; the ingress passes them to the ChatGPT backend
122
+ * on one of the signed-in accounts, so running out on one account moves Codex to the next without
123
+ * a sign-out. A user's own `openai_base_url` is left alone (and then this does not apply).
124
+ */
125
+ function withOpenAiRoute(content, port) {
126
+ const stripped = withoutOwnedBlock(content, OPENAI_START, OPENAI_END);
127
+ if (port === undefined || /^\s*openai_base_url\s*=/m.test(beforeFirstTable(stripped)[0]))
128
+ return stripped;
129
+ const block = `${OPENAI_START}\nopenai_base_url = "http://127.0.0.1:${port}/v1"\n${OPENAI_END}`;
130
+ return `${block}\n\n${stripped}`.replace(/\n+$/, "\n");
131
+ }
132
+ /** Whether Codex's own GPT traffic goes through ClaudeRipple (our route block is in place). */
133
+ export function codexOpenAiRouted(home = codexHome()) {
134
+ try {
135
+ return fs.readFileSync(configFile(home), "utf8").includes(OPENAI_START);
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
117
141
  function profileBlock() {
118
142
  return `${PROFILE_START}
119
143
  model_provider = "clauderipple"
@@ -146,7 +170,7 @@ export function codexOn(port, home = codexHome(), models = []) {
146
170
  const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
147
171
  const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
148
172
  const catalog = models.length ? writeCodexCatalog(models, home) : undefined;
149
- const nextConfig = withCatalog(appendBlock(withoutOwnedBlock(beforeConfig, START, END), providerBlock(port)), catalog);
173
+ const nextConfig = withOpenAiRoute(withCatalog(appendBlock(withoutOwnedBlock(beforeConfig, START, END), providerBlock(port)), catalog), port);
150
174
  const nextProfile = appendBlock(withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END), profileBlock());
151
175
  if (nextConfig === beforeConfig && nextProfile === beforeProfile)
152
176
  return { changed: false, config, profile };
@@ -162,7 +186,7 @@ export function codexOff(home = codexHome()) {
162
186
  const profile = profileFile(home);
163
187
  const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
164
188
  const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
165
- const nextConfig = withoutDanglingSelection(withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END));
189
+ const nextConfig = withoutDanglingSelection(withoutOwnedBlock(withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END), OPENAI_START, OPENAI_END));
166
190
  const nextProfile = withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END);
167
191
  fs.rmSync(catalogFile(home), { force: true });
168
192
  if (nextConfig === beforeConfig && nextProfile === beforeProfile)
@@ -340,8 +340,8 @@ function help() {
340
340
  config print the config file path
341
341
  ui open the dashboard in your browser
342
342
  tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
343
- login sign in to ChatGPT (opens your browser; tokens stay in the home dir)
344
- logout forget the ChatGPT login made with "login"
343
+ login add a ChatGPT account (opens your browser; run again to add another — they take turns when one runs out)
344
+ logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
345
345
  claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
346
346
  claude-logout remove every Claude subscription added to ClaudeRipple
347
347
  picker on|off show your mapped models by name in the Claude Desktop picker (trusts the CA in your login keychain, routes the app through ClaudeRipple)
@@ -426,17 +426,24 @@ try {
426
426
  }
427
427
  case "login": {
428
428
  const { login } = await import("../../router/src/providers/chatgpt/auth.js");
429
- console.log("Opening your browser to sign in to ChatGPT. Sign in there; this window waits up to 5 minutes.");
430
- const t = await login(homeDir(), (url) => {
429
+ const { saveChatGptAccount, readChatGptAccounts, chatgptAccountsPath } = await import("../../router/src/providers/chatgpt/accounts.js");
430
+ const before = readChatGptAccounts(homeDir()).length;
431
+ console.log(before > 0
432
+ ? `Opening your browser to add a ChatGPT account (${before} signed in already). Sign in with the account to add; this window waits up to 5 minutes.`
433
+ : "Opening your browser to sign in to ChatGPT. Sign in there; this window waits up to 5 minutes.");
434
+ const grant = await login((url) => {
431
435
  if (!openBrowser(url))
432
436
  console.log(`Open this URL manually:\n${url}`);
433
437
  });
434
- console.log(`✓ signed in (account ${t.accountId.slice(0, 8)}…, token valid until ${new Date(t.expiresAt).toLocaleString()}). Stored in ${homeDir()}/chatgpt-auth.json`);
438
+ const saved = saveChatGptAccount(homeDir(), grant);
439
+ const total = readChatGptAccounts(homeDir()).length;
440
+ console.log(`${saved.added ? "✓ added" : "✓ signed in again as"} ${saved.email ?? saved.label} (token valid until ${new Date(grant.expiresAt).toLocaleString()}). ${total} ChatGPT account${total === 1 ? "" : "s"} in ${chatgptAccountsPath(homeDir())}`);
435
441
  break;
436
442
  }
437
443
  case "logout": {
438
- const { logout } = await import("../../router/src/providers/chatgpt/auth.js");
439
- console.log(logout(homeDir()) ? "✓ ChatGPT login removed" : "no ChatGPT login stored");
444
+ const { removeAllChatGptAccounts } = await import("../../router/src/providers/chatgpt/accounts.js");
445
+ const removed = removeAllChatGptAccounts(homeDir());
446
+ console.log(removed > 0 ? `✓ removed ${removed} ChatGPT account${removed === 1 ? "" : "s"}` : "no ChatGPT account stored");
440
447
  break;
441
448
  }
442
449
  case "claude-login": {
@@ -4,8 +4,10 @@
4
4
  // GET /api/config raw config.json
5
5
  // PUT /api/config validate + atomically save config.json (router picks it up via mtime)
6
6
  // GET /api/logs?n= tail of router.log
7
- // POST /api/chatgpt-login begin ChatGPT browser login without holding the request open
7
+ // POST /api/chatgpt-login begin ChatGPT browser login (adds an account) without holding the request open
8
8
  // GET /api/chatgpt-login ChatGPT browser login state and credential status
9
+ // GET /api/chatgpt-accounts?provider= signed-in ChatGPT accounts, rotation state, quota (no tokens)
10
+ // PATCH/DELETE /api/chatgpt-accounts/:id rename, pause/resume, clear a cooldown; remove
9
11
  // GET /* static files from packages/ui (the GUI itself)
10
12
  import crypto from "node:crypto";
11
13
  import fs from "node:fs";
@@ -29,6 +31,7 @@ import { syncModelSlots } from "../../cli/src/settings.js";
29
31
  import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
30
32
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
31
33
  import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
34
+ import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
32
35
  const MAX_BODY = 1024 * 1024;
33
36
  const here = path.dirname(fileURLToPath(import.meta.url));
34
37
  const STARTED_AT = new Date().toISOString();
@@ -186,7 +189,7 @@ function tcpReachable(hostname, port, timeoutMs = 2000) {
186
189
  * the first request is ever made. "auto" accepts either our own login or a Codex CLI one.
187
190
  */
188
191
  export function chatgptSignedIn(mode) {
189
- const own = fs.existsSync(path.join(homeDir(), "chatgpt-auth.json"));
192
+ const own = readChatGptAccounts(homeDir()).length > 0;
190
193
  const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
191
194
  return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
192
195
  }
@@ -305,7 +308,9 @@ async function buildStatus(deps, opts = {}) {
305
308
  pointsAtRouter: env.HTTPS_PROXY === wantProxy,
306
309
  },
307
310
  cliVersion: cliVersion(),
308
- chatgpt: { ...chatgpt, signedIn, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
311
+ // `quota` stays one snapshot per provider (the account in use) for the readers that want one
312
+ // number; `accounts` is the per-account view, read after the refresh above so it is as fresh.
313
+ chatgpt: { ...chatgpt, signedIn, accounts: chatgpt.accounts?.() ?? {}, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
309
314
  // Only present for providers that declare a pool; a provider with one credential has nothing
310
315
  // to report and would only add a row that never changes.
311
316
  credentials: deps.credentials?.() ?? {},
@@ -1136,7 +1141,72 @@ export function startAdmin(deps) {
1136
1141
  }
1137
1142
  if (pathname === "/api/chatgpt-login" && method === "GET") {
1138
1143
  const provider = Object.values(deps.config().providers).find((candidate) => candidate.type === "chatgpt");
1139
- sendJson(res, 200, { ...chatgptLogin, signedIn: chatgptSignedIn(provider?.auth) });
1144
+ sendJson(res, 200, { ...chatgptLogin, signedIn: chatgptSignedIn(provider?.auth), accountCount: readChatGptAccounts(homeDir()).length });
1145
+ return;
1146
+ }
1147
+ // Safe account metadata only: labels, emails, plan, rotation state and quota. Tokens and the
1148
+ // workspace id stay in the router.
1149
+ if (pathname === "/api/chatgpt-accounts" && method === "GET") {
1150
+ const all = deps.chatgpt?.().accounts?.() ?? {};
1151
+ const wanted = new URL(url, "http://127.0.0.1").searchParams.get("provider");
1152
+ const name = wanted && all[wanted] ? wanted : Object.keys(all)[0];
1153
+ const accounts = name ? all[name] : readChatGptAccounts(homeDir()).map(summarizeChatGptAccount);
1154
+ sendJson(res, 200, { provider: name ?? null, accounts });
1155
+ return;
1156
+ }
1157
+ if (pathname.startsWith("/api/chatgpt-accounts/") && (method === "PATCH" || method === "DELETE")) {
1158
+ let id;
1159
+ try {
1160
+ id = decodeURIComponent(pathname.slice("/api/chatgpt-accounts/".length));
1161
+ }
1162
+ catch {
1163
+ sendJson(res, 400, { error: "invalid account id" });
1164
+ return;
1165
+ }
1166
+ const own = id === "legacy" || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
1167
+ if (!own && id !== "codex") {
1168
+ sendJson(res, 400, { error: "invalid account id" });
1169
+ return;
1170
+ }
1171
+ if (method === "DELETE") {
1172
+ if (id === "codex") {
1173
+ sendJson(res, 409, { error: "the Codex CLI's login belongs to Codex; sign out there instead" });
1174
+ return;
1175
+ }
1176
+ if (!removeChatGptAccount(homeDir(), id)) {
1177
+ sendJson(res, 404, { error: "ChatGPT account not found" });
1178
+ return;
1179
+ }
1180
+ deps.log.info(`admin: removed ChatGPT account ${id.slice(0, 8)}`);
1181
+ sendJson(res, 200, { ok: true });
1182
+ return;
1183
+ }
1184
+ let change;
1185
+ try {
1186
+ change = JSON.parse((await readBody(req)).toString("utf8"));
1187
+ }
1188
+ catch {
1189
+ sendJson(res, 400, { error: "invalid JSON" });
1190
+ return;
1191
+ }
1192
+ if ((change.label !== undefined && (typeof change.label !== "string" || !change.label.trim())) || (change.paused !== undefined && typeof change.paused !== "boolean")) {
1193
+ sendJson(res, 400, { error: "expected {label?: non-empty string, paused?: boolean, clearCooldown?: true}" });
1194
+ return;
1195
+ }
1196
+ if (change.clearCooldown === true)
1197
+ deps.chatgpt?.().clearCooldown?.(id);
1198
+ if (change.label !== undefined || change.paused !== undefined) {
1199
+ if (id === "codex") {
1200
+ sendJson(res, 409, { error: "the Codex CLI's login cannot be renamed or paused here" });
1201
+ return;
1202
+ }
1203
+ if (!updateChatGptAccount(homeDir(), id, { ...(typeof change.label === "string" ? { label: change.label } : {}), ...(typeof change.paused === "boolean" ? { paused: change.paused } : {}) })) {
1204
+ sendJson(res, 404, { error: "ChatGPT account not found" });
1205
+ return;
1206
+ }
1207
+ }
1208
+ deps.log.info(`admin: updated ChatGPT account ${id.slice(0, 8)}`);
1209
+ sendJson(res, 200, { ok: true });
1140
1210
  return;
1141
1211
  }
1142
1212
  if (pathname === "/api/chatgpt-login" && method === "POST") {
@@ -85,7 +85,7 @@ const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstrea
85
85
  });
86
86
  const observedClaudeCodeAuth = new ObservedClaudeCodeAuth(path.join(home, "claude-auth-observed.json"));
87
87
  const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth });
88
- const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth });
88
+ const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name) });
89
89
  process.on("uncaughtException", (e) => log.error(`uncaught ${e.stack ?? e}`));
90
90
  process.on("unhandledRejection", (e) => log.error(`unhandled ${e?.stack ?? e}`));
91
91
  const DRAIN_MS = 90_000; // new model calls are refused during drain, so this is the longest single call we wait for; `clauderipple restart` waits 120s
@@ -142,7 +142,14 @@ proxy
142
142
  health: () => health.consecutiveFailures,
143
143
  version: VERSION,
144
144
  requests,
145
- chatgpt: () => ({ quota: proxy.chatgptRateLimits, auth: proxy.chatgptAuthStatus(), refresh: (name) => proxy.chatgptFetchRateLimits(name), models: (name) => proxy.chatgptFetchModels(name) }),
145
+ chatgpt: () => ({
146
+ quota: proxy.chatgptRateLimits,
147
+ auth: proxy.chatgptAuthStatus(),
148
+ refresh: (name) => proxy.chatgptFetchRateLimits(name),
149
+ models: (name) => proxy.chatgptFetchModels(name),
150
+ accounts: () => proxy.chatgptAccounts(),
151
+ clearCooldown: (id) => proxy.chatgptClearCooldown(id),
152
+ }),
146
153
  credentials: () => proxy.credentialHealth(),
147
154
  picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
148
155
  observedClaudeCodeAuth,
@@ -5,6 +5,7 @@ import http from "node:http";
5
5
  import https from "node:https";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
+ import zlib from "node:zlib";
8
9
  import { providerFor } from "../config.js";
9
10
  import { resolve } from "../routing.js";
10
11
  import { PRESETS } from "../presets.js";
@@ -19,6 +20,38 @@ import { ObservedClaudeCodeAuth } from "../providers/anthropic-observed.js";
19
20
  import { ResponsesEventMapper, chatToAnthropic, formatDataSse, formatSse, responsesEventsToChatChunks, responsesToAnthropic, responsesToChatCompletion, } from "./translate.js";
20
21
  const MAX_BODY = 64 * 1024 * 1024;
21
22
  const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length", "authorization"]);
23
+ /**
24
+ * The request body as JSON text. Codex signed in to ChatGPT compresses what it sends with zstd
25
+ * (`content-encoding: zstd`, measured 2026-09-24 with the Codex CLI through `openai_base_url`), so
26
+ * reading it raw fails before the model is even known. Throws on an encoding we cannot read.
27
+ */
28
+ export function decodeRequestBody(raw, encoding) {
29
+ const name = (Array.isArray(encoding) ? encoding.join(",") : encoding ?? "").trim().toLowerCase();
30
+ if (!name || name === "identity")
31
+ return raw;
32
+ if (name === "zstd")
33
+ return zlib.zstdDecompressSync(raw);
34
+ if (name === "gzip" || name === "x-gzip")
35
+ return zlib.gunzipSync(raw);
36
+ if (name === "deflate")
37
+ return zlib.inflateSync(raw);
38
+ if (name === "br")
39
+ return zlib.brotliDecompressSync(raw);
40
+ throw new Error(`unsupported content-encoding ${name}`);
41
+ }
42
+ /** A model Codex means for OpenAI: in the chatgpt provider's list, or named the way OpenAI names them. */
43
+ export function isChatGptModel(model, cfg) {
44
+ if (/^(gpt-|codex-|chatgpt-|o\d)/i.test(model))
45
+ return true;
46
+ return Object.values(cfg.providers).some((p) => p.type === "chatgpt" && (p.models ?? []).some((m) => m.id === model));
47
+ }
48
+ /** The conversation a Codex request belongs to, as Codex names it: its session header, else the cache key. */
49
+ function codexConversation(req, body) {
50
+ const header = req.headers.session_id ?? req.headers["session-id"] ?? req.headers["thread-id"];
51
+ if (typeof header === "string" && header)
52
+ return header;
53
+ return body && typeof body.prompt_cache_key === "string" ? body.prompt_cache_key : undefined;
54
+ }
22
55
  function openAiError(message, type = "invalid_request_error", code = null) {
23
56
  return { error: { message, type, param: null, code } };
24
57
  }
@@ -107,6 +140,11 @@ export class OpenAiIngress {
107
140
  });
108
141
  this.server = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => void this.handle(req, res));
109
142
  this.server.keepAliveTimeout = 65_000;
143
+ // Codex tries a WebSocket for Responses first when its OpenAI provider points here. Answering
144
+ // 426 is what makes it fall back to SSE over HTTP; an unanswered upgrade leaves it hanging.
145
+ this.server.on("upgrade", (_req, socket) => {
146
+ socket.end("HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n");
147
+ });
110
148
  this.server.on("connection", (socket) => {
111
149
  this.sockets.add(socket);
112
150
  socket.once("close", () => this.sockets.delete(socket));
@@ -148,7 +186,7 @@ export class OpenAiIngress {
148
186
  async handle(req, res) {
149
187
  const path = (req.url ?? "/").split("?")[0] ?? "/";
150
188
  const method = req.method ?? "GET";
151
- const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/chat/completions");
189
+ const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/responses/compact" || path === "/v1/chat/completions");
152
190
  this.stats.started++;
153
191
  this.stats.inFlight++;
154
192
  if (isCall)
@@ -193,6 +231,15 @@ export class OpenAiIngress {
193
231
  finish(499, 0, { note: "client closed" });
194
232
  });
195
233
  try {
234
+ const query = (req.url ?? "").includes("?") ? (req.url ?? "").slice((req.url ?? "").indexOf("?")) : "";
235
+ // Codex signed in to ChatGPT refreshes its model catalogue with `?client_version=`: that is the
236
+ // backend's list, so it comes from the backend, on one of the accounts.
237
+ if (path === "/v1/models" && method === "GET" && /[?&]client_version=/.test(query) && this.deps.chatgpt) {
238
+ req.resume();
239
+ const outcome = await this.deps.chatgpt(null).passthrough(req, res, `/models${query}`, undefined, undefined);
240
+ finish(outcome.status, outcome.bytes, { note: "codex models passthrough" });
241
+ return;
242
+ }
196
243
  if (path === "/v1/models" && method === "GET") {
197
244
  const data = ingressModels(this.deps.config()).map((m) => ({ id: m.id, object: "model", created: 0, owned_by: m.provider }));
198
245
  const bytes = sendJson(res, 200, { object: "list", data });
@@ -219,8 +266,10 @@ export class OpenAiIngress {
219
266
  return;
220
267
  }
221
268
  let body;
269
+ let raw;
222
270
  try {
223
- body = JSON.parse((await readBody(req)).toString("utf8"));
271
+ raw = await readBody(req);
272
+ body = JSON.parse(decodeRequestBody(raw, req.headers["content-encoding"]).toString("utf8"));
224
273
  }
225
274
  catch (error) {
226
275
  const bytes = sendJson(res, 400, openAiError(`Invalid JSON request: ${error.message}`));
@@ -239,10 +288,35 @@ export class OpenAiIngress {
239
288
  }
240
289
  const requested = typeof body.model === "string" ? body.model : "";
241
290
  const cfg = this.deps.config();
291
+ const explicit = resolve(requested, {}, cfg);
292
+ const explicitProvider = explicit ? cfg.providers[explicit.provider] : undefined;
293
+ // Codex's own GPT traffic (its OpenAI provider pointed here): passed through to the ChatGPT
294
+ // backend on one of the signed-in accounts. A GPT model routed elsewhere on purpose is not.
295
+ const toChatGpt = path.startsWith("/v1/responses") && this.deps.chatgpt
296
+ && (explicitProvider ? explicitProvider.type === "chatgpt" : isChatGptModel(requested, cfg));
297
+ if (toChatGpt) {
298
+ const adapter = this.deps.chatgpt(explicitProvider ? explicit.provider : null);
299
+ // As sent, compressed or not; only a mapping to another model name means rewriting it (and
300
+ // then it goes out uncompressed).
301
+ const renamed = explicit && explicit.model !== requested;
302
+ const payload = renamed ? Buffer.from(JSON.stringify({ ...body, model: explicit.model })) : raw;
303
+ record = { kind: "messages", source: requested, target: explicit?.model ?? requested, provider: adapter.name, stream: body.stream === true };
304
+ const outcome = await adapter.passthrough(req, res, path.slice("/v1".length), payload, codexConversation(req, body), {
305
+ bodyEncoded: !renamed,
306
+ onCompleted: (done) => { terminal = { status: done.status, bytes: done.bytes, ...(done.usage ? { usage: done.usage } : {}), ...(done.note ? { note: done.note } : {}) }; },
307
+ });
308
+ finish(outcome.status, outcome.bytes, { ...(outcome.usage ? { usage: outcome.usage } : {}), ...(outcome.note ? { note: outcome.note } : {}) });
309
+ return;
310
+ }
311
+ if (path === "/v1/responses/compact") {
312
+ const bytes = sendJson(res, 400, openAiError("Remote compaction is only available for ChatGPT models", "invalid_request_error", "unsupported_endpoint"));
313
+ finish(400, bytes);
314
+ return;
315
+ }
242
316
  // Unmapped models default to the native `anthropic` provider when one is configured, so a
243
317
  // Codex user can name any Claude model directly (`-m claude-sonnet-5`) without a mapping.
244
318
  const fallback = Object.entries(cfg.providers).find(([, p]) => p.type === "anthropic");
245
- const route = resolve(requested, {}, cfg) ?? (fallback && requested ? { provider: fallback[0], model: requested, effort: undefined, tag: `${requested}->${requested}` } : null);
319
+ const route = explicit ?? (fallback && requested ? { provider: fallback[0], model: requested, effort: undefined, tag: `${requested}->${requested}` } : null);
246
320
  if (!route) {
247
321
  const bytes = sendJson(res, 400, openAiError(`No ClaudeRipple route for model ${requested || "(missing)"}`, "invalid_request_error", "model_not_found"));
248
322
  finish(400, bytes);