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
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 |
|
|
127
|
-
|
|
128
|
-
| 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ❌
|
|
129
|
-
| claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ❌ | ❌ |
|
|
130
|
-
| Claude 구독과 GPT 구독 나란히 | ✅ | ❌
|
|
131
|
-
| Desktop 피커에 실제 모델 이름 | ✅ | ❌ |
|
|
132
|
-
|
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
|
|
|
136
|
-
|
|
|
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 백엔드 | 로그인(
|
|
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 |
|
|
143
|
-
|
|
144
|
-
| Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ | ❌
|
|
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 | ✅ | ❌ |
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
|
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
|
|
|
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
|
|
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) |
|
package/dist/cli/src/codex.js
CHANGED
|
@@ -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)
|
package/dist/cli/src/index.js
CHANGED
|
@@ -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
|
|
344
|
-
logout forget
|
|
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
|
-
|
|
430
|
-
const
|
|
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
|
-
|
|
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 {
|
|
439
|
-
|
|
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": {
|
package/dist/router/src/admin.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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") {
|
package/dist/router/src/index.js
CHANGED
|
@@ -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: () => ({
|
|
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
|
-
|
|
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 =
|
|
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);
|