clauderipple 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
@@ -0,0 +1,441 @@
1
+ # ClaudeRipple — Architecture and verified facts
2
+
3
+ Last verified: 2026-09-11 against Claude Desktop 1.52386.0, Claude Code CLI 2.1.266.
4
+ Everything below is backed by a document, a file, or a measured log. Items marked
5
+ **(assumption)** are not.
6
+
7
+ ## 1. The mechanism
8
+
9
+ ```
10
+ ~/.claude/settings.json
11
+ env.HTTPS_PROXY = http://127.0.0.1:<port>
12
+ env.NODE_EXTRA_CA_CERTS = <state dir>/ca.pem
13
+
14
+ CLI: CONNECT api.anthropic.com → ClaudeRipple terminates TLS (leaf cert signed by ca.pem)
15
+ per request:
16
+ model matches a route → rewrite model (+effort), send to provider adapter
17
+ otherwise → forward byte-for-byte to api.anthropic.com over real TLS
18
+ CLI: CONNECT anything else → blind tunnel
19
+ ```
20
+
21
+ Why this path is stable:
22
+
23
+ - Anthropic's [corporate proxy docs](https://code.claude.com/docs/en/corporate-proxy)
24
+ state that in Desktop sessions the CLI reads `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`
25
+ and `NODE_EXTRA_CA_CERTS` from managed settings and `~/.claude/settings.json`,
26
+ and that before v2.1.217 these were ignored. The change was deliberate and its
27
+ direction was "narrow the sources", not "remove". Enterprise TLS-inspecting
28
+ proxies (Zscaler, Netskope) are named as supported.
29
+ - The app itself does **not** pass `settings.json` env to the CLI. The CLI reads
30
+ it. The app only injects OS proxy settings and its own CA bundle, and only when
31
+ the caller env does not already have the key (`NXe` merge in
32
+ `index.chunk-C0sgyfNn.js`). Managed (enterprise) settings are the one tier the
33
+ app force-overrides. App updates therefore do not touch this path; **CLI
34
+ updates are the only moving part.**
35
+ - The app auto-updates the CLI independently of the app (checks
36
+ `downloads.claude.ai/claude-code-releases/stable` every ~4h, signed manifest).
37
+ Health checks must key on the CLI version directory
38
+ `~/Library/Application Support/Claude/claude-code/<ver>/`.
39
+ - No certificate pinning on the CLI spawn path. `setCertificateVerifyProc` exists
40
+ only for the Chrome-extension pairing partition; `rejectUnauthorized` only for
41
+ the remote-control websocket.
42
+ - The CA is never installed in the keychain. `NODE_EXTRA_CA_CERTS` is a Node-only
43
+ trust addition, no admin password, no effect on other programs.
44
+
45
+ ## 2. What the app does with the official third-party setting (why competitors lose)
46
+
47
+ Verified in `app.asar` (`.vite/build/index.chunk-C0sgyfNn.js`, `-2CuMdv3h.js`):
48
+
49
+ - The gateway setting (`inferenceGatewayBaseUrl`) is a **whole-app deployment
50
+ mode switch** (`Wl()` → 3P class `Jqt` vs 1P class `Qqt`).
51
+ - 3P mode loads `app://localhost` (local `ion-dist` bundle) instead of
52
+ `https://claude.ai`; claude.ai `/api/`, `/v1/` calls are stubbed with
53
+ `custom_3p_not_available` 503.
54
+ - 3P "Chat" is a Claude Code `local-agent` session, not claude.ai chat.
55
+ - 3P disables the Sessions bridge (Remote Control): `shouldEnableSessionsBridge(){return!1}`,
56
+ `hasClaudeAiProductFeatures(){return!1}`, side sessions unavailable.
57
+ - 1P mode routes nothing through the gateway (`managesProviderRouting(){return!1}`).
58
+ Partial application ("chat on Claude, Code on gateway") is impossible for anyone.
59
+
60
+ Consequence: the product boundary is the **Code tab and its subagents**. General
61
+ chat is out of reach for every approach, ours included.
62
+
63
+ ## 3. Model picker
64
+
65
+ - The picker list is produced by the claude.ai web frontend (remote renderer),
66
+ from the `model_selector_config` field of the claude.ai **bootstrap** response
67
+ (also pushed live via `bootstrap_push_revision`). Verified in
68
+ `shared-common-1-rSOeStkD.js` (`model_selector_config:e.model_selector_config`)
69
+ and `shared-2-x4ME0hE_.js` (`modelSelectorConfig` → catalog → picker).
70
+ - The renderer reports the list to the app via IPC `setAvailableCodeModels(modelIds)`
71
+ (`shared-16-sxOyUBaU.js`). The 1P app stores it and **does not validate**
72
+ (`validateSessionModel(e,t){return{ok:!0}}`). The chosen id goes to the CLI as
73
+ `--model <id>` or a `set_model` control request, string-checked only.
74
+ - So: if the picker ever shows `gpt-5.6-sol`, the rest of the chain passes it
75
+ through unchanged. But the picker list travels over claude.ai web traffic in the
76
+ Electron renderer, which a CLI-only proxy cannot see.
77
+ - **v1 decision: alias mode.** Map existing picker entries (e.g. Opus 4.8 →
78
+ GPT-6 Astra) and make the mapping visible in the ClaudeRipple GUI.
79
+ - **Picker mode (implemented 2026-09-11, `clauderipple picker on`).** Verified in
80
+ the app's main bundle (`index.pre.js`): the app reads `egressProxyUrl` from its
81
+ own Config Library (`~/Library/Application Support/Claude-3p/configLibrary/
82
+ _meta.json` → `appliedId` → `<uuid>.json`, flat keys; the `-3p` userData
83
+ suffix is used in both deployment modes, `AW()` in `index.pre.js` — the plain
84
+ `Claude/` dir is NOT read, measured 2026-09-13: app log said "proxy for
85
+ https://claude.ai resolved (direct)") and applies it at start
86
+ as Chromium switches `--proxy-server` + `--proxy-bypass-list` (`DK()`), so the
87
+ renderer's claude.ai traffic goes through the same proxy. No MDM, no OS proxy.
88
+ The bootstrap is fetched by the page itself at
89
+ `/edge-api/bootstrap[/{org}/app_start]?statsig_hashing_algorithm=djb2&...`
90
+ (`window.__BOOTSTRAP_PRELOAD__`), top-level `model_selector_config`. The CA is
91
+ trusted in the **login keychain only** (`security add-trusted-cert -r trustRoot
92
+ -k login.keychain-db`; the user types their password in macOS's dialog). The
93
+ router mints a `claude.ai` leaf from the CA (SNI), passes claude.ai through
94
+ byte-for-byte except the bootstrap, where `cli.extraModels` are cloned from an
95
+ enabled Claude entry into every non-`chat` surface. Open question until the
96
+ first real run: exact surface ids and whether the renderer filters ids; the
97
+ router logs `PICKER injected … surfaces: …` on each bootstrap.
98
+ - Injecting `additional_model_options` into the Claude Code bootstrap response
99
+ does **not** reach the app picker (measured; the app never reads that field).
100
+ - **Windows uses the same mechanism** (measured end to end in a Windows 11 VM,
101
+ 2026-09-14: picker listed the GPT models, a call routed, the model answered).
102
+ Two things differ and nothing else does:
103
+ - The Config Library lives at `%LOCALAPPDATA%\Claude-3p\configLibrary\`, not
104
+ `%APPDATA%`. The app creates `Claude-3p` itself but leaves it empty —
105
+ `configLibrary/` is ours to write, on both platforms (`_meta.json` had
106
+ `previousAppliedId: null` on a machine that had never been touched).
107
+ - The CA goes into `Cert:\CurrentUser\Root`. Windows shows a confirmation
108
+ dialog with the fingerprint instead of asking for a password, and **no UAC
109
+ prompt**: a standard, non-elevated user can do it. It asks a second time when
110
+ the certificate is removed, so `picker off` prompts too.
111
+ The app's own log line confirms the proxy took effect:
112
+ `[egress-proxy] pinned to fixed proxy at 127.0.0.1:<port>; OS proxy settings ignored`.
113
+
114
+ ## 4. Provider adapters
115
+
116
+ - **Anthropic-compatible providers** (DeepSeek, Kimi/Moonshot, GLM, MiniMax and
117
+ others expose `/v1/messages`): host + model rewrite only, no translation. This
118
+ is the whole reason `claude-code-router` works with just `ANTHROPIC_BASE_URL`.
119
+ **(assumption — verify each provider's endpoint before shipping a preset.)**
120
+ A routed request authenticates as the provider and only as the provider: the
121
+ caller's `authorization` and `x-api-key` are dropped before the provider's own
122
+ headers are added. Presets differ in which header they use (`x-api-key` for
123
+ DeepSeek and MiniMax, `Authorization: Bearer` for the rest), so relying on the
124
+ provider header to overwrite the caller's by name covers one of the two at
125
+ best. An un-routed request keeps its headers — that one really is going to
126
+ Anthropic. Enforced by `packages/router/test/proxy-headers.test.ts`.
127
+ - **ChatGPT subscription (Codex backend):** needs a translation layer,
128
+ Anthropic Messages ⇄ OpenAI Responses, plus OAuth (PKCE) login against
129
+ `chatgpt.com/backend-api/codex`. The upstream endpoint is unofficial and may
130
+ change.
131
+ - Reference implementations, used as **behavioral specs only, no code copied**:
132
+ [husniadil/proxenos](https://github.com/husniadil/proxenos) (Rust, Apache-2.0;
133
+ measured cache hit 97.8–98.7% in daily use) and
134
+ [insightflo/chatgpt-codex-proxy](https://github.com/insightflo/chatgpt-codex-proxy)
135
+ (TypeScript, MIT; OAuth, tool calling, SSE).
136
+ - Cache is the make-or-break metric. An earlier translator of our own (a
137
+ GPT layer over the Claude Code harness, before ClaudeRipple) lost
138
+ `cache_control` breakpoints and re-signed reasoning, giving 12–20% cache
139
+ hit and 8× input token spend. That figure is ours, not a measurement of
140
+ opencodex or any other project. **Acceptance: ≥90% cache hit on a
141
+ multi-turn tool-using session, measured from upstream
142
+ `input_tokens_details.cached_tokens`.**
143
+ - `output_config.effort`: `none…max` accepted by terra/sol/astra, `ultra`
144
+ rejected (luna accepts it). Clamp `ultra` → `max`.
145
+ - Model self-introduction is not evidence of routing. Verify by upstream
146
+ usage records.
147
+ - **Implemented 2026-09-11** in `packages/router/src/providers/chatgpt/`.
148
+ Wire facts used: endpoint `POST {base}/codex/responses` with base
149
+ `https://chatgpt.com/backend-api`; headers `authorization: Bearer`,
150
+ `chatgpt-account-id`, `OpenAI-Beta: responses=experimental`,
151
+ `originator: codex_cli_rs`; body fields `model, instructions, input, tools,
152
+ tool_choice, parallel_tool_calls, reasoning{effort,summary}, text{verbosity},
153
+ store:false, stream:true, prompt_cache_key`. OAuth: `auth.openai.com/oauth/
154
+ authorize|token`, client `app_EMoamEEZ73f0CkXaXp7hrann`, redirect
155
+ `http://localhost:1455/auth/callback`, PKCE S256, account id from the access
156
+ token claim `https://api.openai.com/auth.chatgpt_account_id`.
157
+ - **Verified live 2026-09-13 and cut over** (the user's config now uses this
158
+ adapter; proxenos is no longer in the chain). Measured against the real
159
+ backend: streamed text, tool calls (`tool_use` + `input_json_delta`),
160
+ multi-turn with `tool_result`, non-streaming, `count_tokens` (local
161
+ estimate), `@effort` suffix → `reasoning.effort`. Prompt cache on
162
+ `gpt-5.6-terra`: 0 on the first call after idle, then **7680/7801 = 98.4%**
163
+ on every following call with the same `prompt_cache_key`. `gpt-5.6-luna`
164
+ reported `cached_tokens: 0` on identical repeats (backend behavior, also
165
+ through proxenos) — do not judge the cache metric on luna.
166
+ - Quota comes from the backend's **`x-codex-*` response headers** on every
167
+ response (`x-codex-primary-used-percent`, `-window-minutes`,
168
+ `-reset-after-seconds`, `-reset-at`, `x-codex-plan-type`); the
169
+ `codex.rate_limits` SSE event was not sent in any measured response. The
170
+ adapter reads both.
171
+ - **Tool schema scrub.** The backend validates every `pattern` in
172
+ `tools[].parameters` with a regex engine that rejects lookaround and
173
+ backreferences, and one bad pattern fails the whole request with 400
174
+ `invalid_function_parameters` ("… is not a 'regex'"). Claude Code's
175
+ `Artifact` tool carries `^(?!__.*__$)…` since ~2.1.266, which broke every
176
+ GPT request through proxenos as well. `normalizeSchema` drops such patterns
177
+ (the client validates its own inputs). Upstream error bodies are now logged.
178
+ - Cache-safety decisions: thinking blocks are dropped from replayed history;
179
+ no reasoning `include`; identity line and `instructionsAppend` are constant
180
+ text; `prompt_cache_key` = sha256(metadata.user_id + first user message).
181
+
182
+ ### 4c. What a routed model is told it is
183
+
184
+ - Claude Code's system prompt opens with "You are Claude Code, Anthropic's
185
+ official CLI for Claude". A mapped provider receives that text and has nothing
186
+ else to go on: asked what it was, DeepSeek answered that it was Claude while
187
+ the session label read `deepseek-flash` (measured 2026-09-16).
188
+ - So every provider that assembles a system prompt puts one line in front of it:
189
+ `You are <model> (reasoning effort: <effort>), answering through Claude Code, a
190
+ terminal-based coding agent.` The effort is named because a model cannot see
191
+ its own setting; it is omitted when the provider takes no reasoning effort.
192
+ `packages/router/src/identity.ts` holds the sentence, and the ChatGPT,
193
+ OpenAI-compatible and Anthropic-compatible paths all use it.
194
+ - On the Anthropic-compatible path the line is added **after** the compatibility
195
+ pass, so the effort named is the one the provider actually receives. A string
196
+ `system` stays a string; a block array gains a leading block that carries no
197
+ `cache_control`, leaving the caller's breakpoints where they were.
198
+ - `identity: false` per provider turns it off; `instructionsAppend` adds fixed
199
+ text after the prompt. Both are constant per provider, so the prompt cache
200
+ misses once when either changes and not afterwards.
201
+
202
+ ### 4a. Claude Code request shapes a translator must handle (measured 2026-09-13, CLI 2.1.266)
203
+
204
+ - **Server-side threads ("tether").** The first request of a session carries
205
+ `thread: {type:"create"}` and the full history; later turns carry
206
+ `thread: {type:"continue", previous_message_id: <our last assistant msg id>}` and
207
+ **only the new messages** (tool results + attachments). A translated provider has no
208
+ such state, so the router answers a `continue` with HTTP 400 and
209
+ `error.details.error_code = "thread_unsupported_request"`; the CLI then resends the
210
+ turn stateless and keeps the session stateless on that model. Accepting the delta
211
+ instead made the model see an orphan `tool_result`, answer "무엇을 도와드릴까요?", and
212
+ kept `cached_tokens` stuck at the tools prefix. `thread`/`diagnostics` are stripped
213
+ from `create` requests before forwarding.
214
+ - **Per-turn billing telemetry.** The first system block is
215
+ `x-anthropic-billing-header: … cch=<hash> …` and the hash changes every turn. Dropped
216
+ from `instructions`, otherwise nothing after it is ever cached. With it dropped and
217
+ sessions stateless, continuation turns measured 94–99% cache hit (`cached_tokens` /
218
+ total input) on gpt-5.6-terra.
219
+ - **Orphan tool results.** Side queries can start with a bare `tool_result`; the
220
+ Responses API rejects a `function_call_output` without its `function_call`
221
+ ("No tool call found"), so such results are sent as user text.
222
+ - **Usage snapshots.** The CLI snapshots `message.usage` per streamed content block,
223
+ before `message_delta`; `message_start` therefore announces an input estimate
224
+ (char/4, floored by the last measured total for the conversation) so the app's
225
+ token counter and the CLI's context accounting are not zero.
226
+
227
+ - **Provider base path.** Anthropic-compatible vendors mount the API under a path
228
+ (`https://api.deepseek.com/anthropic`, `https://openrouter.ai/api`,
229
+ `https://dashscope-intl.aliyuncs.com/apps/anthropic`); the router prepends it to
230
+ the CLI's `/v1/messages…`. Forwarding to the bare host returned the vendor's
231
+ website as HTTP 200 HTML (measured 2026-09-13).
232
+ - **Anthropic-only request features.** Claude Code sends `thinking.block_binding`,
233
+ `defer_loading` tools, `context_management`, `output_config.effort`, and an
234
+ `anthropic-beta` header; compatible vendors answer 400 for them
235
+ (`Thinking.block_binding is not supported…`, `Deferred custom tools are only
236
+ supported on Anthropic models…`). `packages/router/src/compat.ts` strips or
237
+ clamps them per provider capability (preset `effortLevels` / `thinking`).
238
+ - **New picker entries need a new session.** The CLI validates `set_model`
239
+ against the model list it received at session start (`additional_model_options`
240
+ in its bootstrap); a model added afterwards is "not a recognized model id"
241
+ until a new session starts.
242
+
243
+ ### 4b. OpenAI ingress (Codex CLI and other OpenAI clients)
244
+
245
+ - A second, plain HTTP listener binds **only** `127.0.0.1` on
246
+ `listen.openaiPort` (default `listen.port + 2`). It accepts any syntactically
247
+ valid local `Authorization: Bearer …` value but never forwards that value.
248
+ It provides `POST /v1/responses`, `POST /v1/chat/completions`, and
249
+ `GET /v1/models`.
250
+ - Both POST endpoints resolve their requested `model` with the same
251
+ `routes`/`aliases`/`direct` logic as the proxy. `chatgpt` and
252
+ `openai-compatible` targets are rejected: Codex already has native paths for
253
+ them. Targets are `anthropic-compatible`, or native `anthropic`.
254
+ - Configure a native API-key target as
255
+ `{ "type":"anthropic", "auth":"api-key", "apiKey":"…" }` (or set
256
+ `ANTHROPIC_API_KEY`). Configure a compatible target as today, for example an
257
+ `openrouter` provider at `https://openrouter.ai/api` with its own `headers`.
258
+ Ingress writes requests to the ordinary RequestLog as `kind: "messages"`,
259
+ with `note: "openai-ingress responses|chat"`.
260
+ - `anthropic.auth: "claude-code"` reuses a Claude subscription credential and
261
+ is subject to Anthropic's terms. Source precedence is: (1) the latest valid
262
+ **in-memory** header snapshot observed from an un-routed Claude Code
263
+ `api.anthropic.com/v1/messages` request (expires after 12 hours and vanishes
264
+ on router restart), (2) `CLAUDE_CODE_OAUTH_TOKEN`, (3) Claude Code Keychain
265
+ service `Claude Code-credentials`.claudeAiOauth, (4)
266
+ `~/.claude/.credentials.json`, then (5) ClaudeRipple's own
267
+ `<home>/claude-auth.json` setup-token file. The observed source retains only
268
+ `Authorization`, `anthropic-version`, `anthropic-beta`, `user-agent`,
269
+ `x-app`, `x-stainless-*`, and `anthropic-client-*` request headers and sends
270
+ that exact set upstream. It is never persisted, logged, included in RequestLog
271
+ or picker diagnostics, debug dumps, or any admin API response. Keychain/file
272
+ reads are cached for 60 seconds; ClaudeRipple **never refreshes or writes**
273
+ Claude Code's own credential. Expiry becomes OpenAI-style 401 `Claude Code
274
+ login expired — open Claude Code once to refresh`.
275
+ - `clauderipple claude-login` (0.1.2) is ClaudeRipple's own browser sign-in:
276
+ the OAuth authorization-code flow with PKCE (S256) against Claude Code's
277
+ public client, exactly as Claude Code 2.1.271 does (read from its binary,
278
+ 2026-09-16): `https://claude.com/cai/oauth/authorize` →
279
+ `https://platform.claude.com/v1/oauth/token`, scopes `org:create_api_key
280
+ user:profile user:inference user:sessions:claude_code user:mcp_servers
281
+ user:file_upload` (the refresh grant names the subscription scopes only).
282
+ The older `claude.ai/oauth/authorize` answers "Invalid request format"
283
+ (measured). The code returns to a loopback listener, port 54545 preferred
284
+ and any free port otherwise (the authorize server accepts any localhost
285
+ port); with `--manual` the redirect is
286
+ `https://platform.claude.com/oauth/code/callback` and the user pastes
287
+ `code#state` (or the redirect URL). State and PKCE verifier are per attempt; a callback
288
+ with the wrong state is refused without ending the attempt; an attempt
289
+ expires after 5 minutes. The grant (access + refresh token, expiry) is stored
290
+ only in `<home>/claude-auth.json` (mode `0600`) as `source: "oauth"`; the
291
+ ingress refreshes it up to 5 minutes before expiry (one refresh shared by
292
+ concurrent requests) and a failed refresh becomes a clear 401 rather than a
293
+ dead token. The GUI drives the same flow through `POST/GET /api/claude-oauth`
294
+ and `POST /api/claude-oauth/code`; no token value ever appears in an admin
295
+ response or a log line. `--setup-token` keeps the previous path (`claude
296
+ setup-token` from a terminal, long-lived token, `source: "setup-token"`).
297
+ `claude-logout` removes the file either way. Wire facts are behaviorally
298
+ measured (the reference implementations do the same flow); they are not an
299
+ Anthropic guarantee, and reuse of a subscription is subject to its terms.
300
+ - Readiness vs liveness (0.1.2): `/api/status.readiness` and `GET /readyz`
301
+ (200 or 503 + `retry-after: 5`) list what stands between a request and a
302
+ model: `settings` (Claude Code not pointed at us), `upstream` (consecutive
303
+ connect failures), `picker-ca` / `picker-proxy` (picker mode without trust or
304
+ the app proxy entry), `provider:<name>` (TCP unreachable). The tray shows the
305
+ list in its detail line. A router that answers is alive; only an empty list
306
+ means it is ready.
307
+ - **OAuth wire behavior (behavioral-spec evidence; not documented public API
308
+ contract):** the behavior-only OpenCodex source uses `Authorization: Bearer`,
309
+ `anthropic-version: 2023-06-01`,
310
+ `anthropic-beta: claude-code-20250219,oauth-2025-04-20`, a Claude Code
311
+ request fingerprint/session ID, and `custom_` prefixes for non-builtin tool
312
+ names. ClaudeRipple independently implements those behaviors; it does not
313
+ copy reference code. The ingress puts the required compatibility first system
314
+ block `You are Claude Code, Anthropic's official CLI for Claude.` and
315
+ **nothing else in `system`**. Measured 2026-09-13 with a live subscription
316
+ credential: a request whose system prompt carries Codex's long instructions
317
+ after the identity line answers HTTP 429 `{"type":"rate_limit_error",
318
+ "message":"Error"}` (not a real quota limit; a tiny request passes at once).
319
+ So in borrowed-login mode the client's `instructions` / system messages travel
320
+ as the first block of the first user message, wrapped in
321
+ `<operator_instructions>` and cache-marked; with an API key they stay in
322
+ `system`. Also measured: `claude-haiku-4-5` rejects `output_config.effort`
323
+ with 400, so effort is sent only to models matching
324
+ `claudeSupportsEffort()` (Opus/Sonnet/Fable 4.6+ and 5). Verified live:
325
+ Sonnet 5 and Haiku 4.5 with Codex's full instructions, tools and effort → 200;
326
+ `codex exec --profile clauderipple -m claude-sonnet-5` → exit 0.
327
+ - Responses conversion is stateless. A non-null `previous_response_id` receives
328
+ HTTP 400 `previous_response_id unsupported` rather than silently losing
329
+ history (Codex sends `null` when it carries the full transcript itself).
330
+ `instructions` plus message/input items become Anthropic `system` plus
331
+ `messages`; text, base64/URL images, function calls, and function outputs are
332
+ preserved; reasoning items are dropped. Function tools and tool choice map to
333
+ Anthropic tools/tool_choice. `max_output_tokens` → `max_tokens`, and
334
+ `temperature` is preserved. `reasoning.effort` → `output_config.effort`; this
335
+ ingress does not invent a thinking-token budget because that would change
336
+ the caller's requested output ceiling.
337
+ - Cache discipline: serialization has no timestamps; cache breakpoints are put
338
+ on the system block, the final tool definition, and the final user content
339
+ block. Upstream Anthropic usage maps `cache_read_input_tokens` to Responses
340
+ `usage.input_tokens_details.cached_tokens`.
341
+ - Anthropic Messages stream conversion emits `response.created`, output-item
342
+ add/done, text and function-argument deltas/done, then `response.completed`.
343
+ Chat Completions uses the same mapper and emits `delta.tool_calls` plus a
344
+ terminal `[DONE]`.
345
+ - Codex 0.146.0-alpha.9.2 on the development machine supports profile layers
346
+ at `$CODEX_HOME/<name>.config.toml` (`--profile <name>`). `clauderipple codex
347
+ on` adds only a marked `[model_providers.clauderipple]` block in
348
+ `$CODEX_HOME/config.toml` and a marked selection block in
349
+ `$CODEX_HOME/clauderipple.config.toml`; `off` removes only those blocks after
350
+ a backup. The provider uses `base_url = "http://127.0.0.1:<openaiPort>/v1"`,
351
+ and `wire_api = "responses"`; no `env_key`, so the Codex desktop app (which
352
+ has no shell environment) can use it too. The ingress accepts a missing
353
+ `Authorization` header and rejects only a malformed one.
354
+
355
+ Sources: [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference),
356
+ [Codex advanced configuration](https://learn.chatgpt.com/docs/config-file/config-advanced),
357
+ [Claude Code authentication and credential management](https://code.claude.com/docs/en/authentication),
358
+ and the behavior-only [OpenCodex source](https://github.com/lidge-jun/opencodex)
359
+ inspected 2026-09-13. The latter is not an Anthropic guarantee.
360
+
361
+ ### 4c. `openai-compatible` providers (implemented 2026-09-13)
362
+
363
+ - This adapter translates Anthropic Messages into either OpenAI **Chat Completions**
364
+ (the default `wire: "chat"`) or stateless **Responses** (`wire: "responses"`),
365
+ then maps SSE back into Anthropic `message_start`, content-block, `message_delta`,
366
+ and `message_stop` events. It supports text, base64/URL images, function tools,
367
+ tool results, non-streaming replies, and local `count_tokens` estimates.
368
+ - Translation deliberately strips `thinking`, `context_management`, `thread`,
369
+ `diagnostics`, and `container`; `thread:continue` is refused exactly as in §4a so
370
+ the CLI resends a full stateless history. The per-turn
371
+ `x-anthropic-billing-header` system block is also stripped because its changing
372
+ value breaks every vendor's stable-prefix cache opportunity. Orphan tool results
373
+ are sent as user text, not a tool result with no antecedent call.
374
+ - `output_config.effort` is emitted only if provider `caps.reasoning` is `"effort"`,
375
+ clamped to `caps.effortLevels`; Chat Completions receives `reasoning_effort`, while
376
+ Responses receives `reasoning.effort`. A discovered model's explicit
377
+ `effortLevels` overrides provider defaults, including `[]` for “no effort”. When
378
+ an OpenRouter `/models` entry has `supported_parameters`,
379
+ `reasoning_effort` maps to `[low, medium, high]`; its absence maps to `[]`.
380
+ Vendors that do not report that field keep their configured fallback. Chat wire
381
+ forwards Anthropic `stop_sequences` as `stop`; Responses wire deliberately omits
382
+ them because its common stateless schema has no corresponding universal field.
383
+ - We do not assert a provider-wide cache hit rate: the adapter records
384
+ `prompt_tokens_details.cached_tokens` / `input_tokens_details.cached_tokens` when
385
+ a vendor returns it, and otherwise logs zero cache read. The byte-stable
386
+ translated history preserves cache eligibility but does not create vendor caching.
387
+ - Presets use OpenAI-compatible `GET /models` discovery followed by a one-token
388
+ `POST /chat/completions` authentication check. 401/403 is a bad key; 402 or an
389
+ insufficient-credit response means the key is accepted but the account has no
390
+ credit. Preset endpoint and capability claims cite the vendor's official docs in
391
+ `packages/router/src/presets.ts`.
392
+
393
+ ## 5. Failure modes that must not exist in the product (all observed)
394
+
395
+ | Observed | Product requirement |
396
+ |---|---|
397
+ | Router stuck with DNS resolution failure for 10h (`gaierror`, 22,795 lines); 11 remote-control workers died within 17s; app kept showing remote as available | Consecutive upstream connect failures → self-exit; supervisor restarts (launchd KeepAlive). Health view shows remote-worker liveness. |
398
+ | Python 3.9 asyncio GC'd connection handler tasks; 1 in 10 requests vanished with no log line (`Task was destroyed but it is pending`); surfaced as "retry" banners, `Connection lost mid-response`, and subagent compaction failures | Keep strong refs to in-flight connections; log every request completion; an integration test that counts requests in vs. responses out. |
399
+ | Translator daemon (proxenos) was not supervised; it died and every GPT request failed with connection refused | One supervisor for the whole chain; the router reports adapter health, not just its own. |
400
+ | `router.log` grew to 17MB | Log rotation by default. |
401
+ | A `restart` (SIGTERM) killed 5 in-flight requests; a streaming answer died mid-response and Claude Desktop showed a connection error (2026-09-11 17:00) | SIGTERM drains: stop accepting, wait for `/v1/messages` calls (up to 90s), then exit. Long-poll worker streams are not waited for (the CLI reconnects them). Never restart the live router casually. |
402
+ | `launchctl kickstart -k` SIGKILLed the router ~5s after its SIGTERM; the drain was cut with 2 model calls open (2026-09-13 13:35) | `clauderipple restart` sends SIGTERM itself, waits for the process to exit (up to 120s), and lets launchd KeepAlive relaunch it. `kickstart -k` is only the fallback. |
403
+ | Drain ran the full budget and still had 2 calls open: `server.close()` stops new TCP connections only, and the CLI kept sending new requests down its existing tunnels (in-flight went 2→1→2; 2026-09-13 13:38) | While draining, new `/v1/messages` requests get `503` + `retry-after: 3` + `connection: close` before the body is read (the SDK retries 5xx and reconnects to the relaunched router). The in-flight count can then only fall. Verified: a 47s stream finished, the next call got 503, exit 1s later. |
404
+ | Every GPT request failed with 400 after a Claude Code update added a lookahead regex to the `Artifact` tool schema (2026-09-13; proxenos too) | Tool-schema scrub in the translator (§4). Upstream error bodies are logged, never just the status. |
405
+ | Unknown-model context window defaulted to 200K, compaction fired at 151K; fixed via `CLAUDE_CODE_MAX_CONTEXT_TOKENS=272000` (applies only to models not in the CLI's built-in table; Claude models unaffected) | Installer sets this env for mapped models; document that it does not affect Claude models. |
406
+ | proxenos sends only the 7-day quota window, so the app shows a "weekly limit" banner | Quota reporting must mirror the shape Anthropic returns. |
407
+ | After a reboot the router listened 4 minutes after login (26s of it between exec and `listen()`), and for that whole window Claude Desktop was a blank page with `ERR_PROXY_CONNECTION_FAILED` — in picker mode every byte the app sends goes through us, so a router that is merely slow reads as an app that is broken (2026-09-14 09:59 boot → 10:15:59 listening) | The launchd agent is `ProcessType=Interactive`, never `Background` (that key throttles CPU and I/O — launchd.plist(5)). `listen()` comes before certificate minting and any other startup work, so a client waits rather than being refused. Every startup logs its budget (`startup Nms: node …, config …, listen …`). |
408
+ | "Start Router" ran `launchctl kickstart -k`, which kills a router that is already coming up and starts the wait over (three runs in the four minutes after login, 2026-09-14) | Starting is idempotent: a running agent is left alone, an unloaded one is re-bootstrapped. Only `restart` may force. |
409
+ | With the router down, the tray app's window loaded the router-served GUI and showed the same blank page as Claude Desktop — nothing anywhere said why | The cause is always named where the user asked for the dashboard. Since the window was dropped (2026-09-16) the tray opens the dashboard in the browser: with the router down it starts it first, and if it does not come up a dialog explains why Claude Desktop is blank. The app keeps a login item so it is there before Claude Desktop is, and posts a notification when the router has been down for 20s. |
410
+ | Windows: the scheduled task inherited the console that started it, so closing the installer window killed the router with `0xC000013A` (Ctrl+C), 2026-09-14 | The task runs a PowerShell launcher that uses `Start-Process -PassThru -Wait`: detached from any console, still tracked so `RestartCount` keeps acting as KeepAlive and the exit code (including the health self-exit 75) propagates. |
411
+ | Windows: `install` registered a logon-triggered task and stopped there, so the router did not exist until the next sign-in — launchd starts at bootstrap (`RunAtLoad`) and the difference was invisible | `install` starts the supervisor itself on both platforms and prints the result. |
412
+ | Windows: trusting the CA failed with "this operation cannot use the UI" — every PowerShell call carried `-NonInteractive`, and the confirmation dialog never appeared, so picker mode stopped with no certificate and no explanation | Certificate trust and removal run WITHOUT `-NonInteractive` (and without `windowsHide`). They are UI operations by design: the OS must be able to show the user what it is being asked to trust. |
413
+ | Windows: sign-in failed with `missing_required_parameter` because the OAuth URL was opened via `cmd /c start`, and cmd reads `&` as a command separator — everything after the first parameter was cut off and run as commands (2026-09-14) | Browsers are opened with `Start-Process <url>` as a single quoted argument. Any URL we hand to a shell must survive its metacharacters. |
414
+ | The provider form's "also show in the Claude app picker" box only records `cli.extraModels`; picker mode itself stayed off, nothing was injected, and the user believed the picker was on (Windows x64, 2026-09-14: `config.json` had no `picker` key, no CA, no Config Library) | When that box is ticked while picker mode is off, the form says so under the box and offers to turn picker mode on right after saving. Any control that *looks* like it enables picker mode must either enable it or say what is still missing. |
415
+ | A DeepSeek mapping answered `401` on every real request while the provider form's own "Test connection" stayed green — the test sends the provider's headers alone, live traffic also carried the caller's `authorization` (2026-09-15, Windows) | A routed request carries provider authentication only (§4). The connection test and live traffic must present the same auth set, or the test certifies a path nobody uses. |
416
+ | Mapping Haiku 4.5 did nothing: the app sends `claude-haiku-4-5-20251001` while the GUI only offers the undated `claude-haiku-4-5`, and route lookup was an exact key match, so the request passed through and Haiku answered itself (2026-09-15) | Route lookup accepts both the dated and undated form of a model id. A mapping the GUI offers must be one the router can actually match. |
417
+ | The native `anthropic` provider (ingress-only) was offered as a mapping target and could be given picker entries; a slot pointed at it made every Claude request fail with `400` (2026-09-15) | Ingress-only providers never appear as mapping targets and create no picker/direct rules. A rule that names one is ignored at resolve time, so an existing config degrades to passthrough instead of failing. |
418
+ | Windows: "Connect Claude subscription" always failed with "Claude Code CLI not found" — discovery looked for an extensionless `claude` on PATH and for the macOS Application Support cache, neither of which exists there (2026-09-15) | Launcher discovery is per-platform: `.exe`/`.cmd`/`.bat` names on Windows, both AppData roots for the Desktop-cached CLI, and a shell for the `.cmd` launcher that cannot be spawned directly. |
419
+ | After updating to 0.1.1 the same Windows install still answered `401` on DeepSeek: the files were new, the router process was the one the scheduled task had started from the old files, and `start` leaves a running router alone. The router also reported `0.1.0` (a literal in two places), so nothing could tell (2026-09-15) | An update replaces the process, not only the files: the app compares the running router's version and files (`/api/status.runtime`) with its own once per launch, re-runs `install` when this installation is not the recorded one, and restarts. One `VERSION` constant, tested against the manifests. |
420
+ | Windows: `restart` on a packaged install never restarted anything — the router runs as `ClaudeRipple.exe` (Electron as Node) and the pid lookup only matched `node.exe`, so no shutdown was sent and `Start-ScheduledTask` was ignored by the task already running; the command still reported "restarted" (found 2026-09-16, review of the update path) | Process discovery matches every runtime we launch, and a restart that finds no process to stop must say so instead of claiming success. The installer's own stop script already matched `ClaudeRipple.exe`; two lookups for the same process must not diverge. |
421
+ | The DeepSeek `401` stayed unexplained for a day: a provider error was logged as a status code only (2026-09-15) | Upstream 4xx/5xx bodies are logged (first 300 characters, key-shaped strings masked) and kept in the request record. This is the same rule already required of the translator above. |
422
+ | "Connect Claude subscription" from the tray/GUI failed with "setup-token failed": `claude setup-token` is an interactive terminal flow and neither place has a terminal (2026-09-15, Windows) | Without a TTY the error says to run `clauderipple claude-login` in a terminal and that an existing Claude Code login is reused anyway; other failures carry what `claude` printed. A button that cannot work where it is must say where it works. |
423
+ | Closing Claude Desktop's window does not quit it; reopening hits `Not main instance, returning early` and the app silently keeps the OLD proxy setting. The user sees "I configured it and nothing happened" with no error anywhere (2026-09-14) | Tell the user that closing the window is not enough, and detect it: with picker mode on, the router knows whether the app is actually routing through it. Surface "configured, but the app has not restarted yet" rather than letting it fail silently. |
424
+
425
+ ## 6. Blocked paths (measured, do not retry)
426
+
427
+ - The app puts `ANTHROPIC_BASE_URL` in the CLI process env directly;
428
+ `settings.json` env does not override an existing variable; project-scope
429
+ `.claude/settings.local.json` env is ignored for base URL.
430
+ - `ANTHROPIC_UNIX_SOCKET` does not apply.
431
+ - `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` is set only in 3P/Cowork.
432
+ - 3P mode is all-or-nothing at app launch and loses connectors.
433
+
434
+ ## 7. Competitive position (2026-09-11)
435
+
436
+ Six repos target the app (ModelLink 135★ best); all use the official gateway
437
+ setting and pay its costs (§2). Four are Chinese-market only; ModelLink is
438
+ CC BY-NC-ND (no forks, no sponsors). CLI-targeting `claude-code-router` has
439
+ 37,180★, `opencodex` 14,300★ with two README sponsors. Open positions: English
440
+ distribution, GPL-3.0 license (chosen 2026-09-13 over MIT: sole author, reversible later, blocks closed commercial repackaging), sponsor slots. Positioning: an **add-on for Claude
441
+ subscribers**, not a replacement for people without one.
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "clauderipple",
3
+ "version": "0.2.0",
4
+ "description": "Use GPT and 400+ other models inside the Claude Desktop app while staying signed in to your Claude subscription — no third-party gateway mode.",
5
+ "license": "GPL-3.0-only",
6
+ "workspaces": [
7
+ "packages/*"
8
+ ],
9
+ "engines": {
10
+ "node": ">=24"
11
+ },
12
+ "scripts": {
13
+ "typecheck": "tsc -p tsconfig.json --noEmit",
14
+ "dist": "npm --workspace @clauderipple/app run dist",
15
+ "release": "npm --workspace @clauderipple/app run release",
16
+ "router": "node packages/router/src/index.ts",
17
+ "test": "node --test packages/router/test/*.test.ts packages/cli/test/*.test.ts",
18
+ "cli": "node packages/cli/src/index.ts",
19
+ "prepack": "node scripts/build-npm.mjs",
20
+ "build": "node scripts/build-npm.mjs"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^24.3.0",
24
+ "typescript": "^5.9.2"
25
+ },
26
+ "type": "module",
27
+ "bin": {
28
+ "clauderipple": "bin/clauderipple.js"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "dist/",
33
+ "docs/ARCHITECTURE.md",
34
+ "CHANGELOG.md",
35
+ "README.ko.md"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/PBJ-2/clauderipple.git"
43
+ },
44
+ "keywords": [
45
+ "claude",
46
+ "claude-desktop",
47
+ "claude-code",
48
+ "anthropic",
49
+ "gpt",
50
+ "chatgpt",
51
+ "openai",
52
+ "codex",
53
+ "deepseek",
54
+ "kimi",
55
+ "openrouter",
56
+ "llm",
57
+ "proxy",
58
+ "model-router",
59
+ "ai-coding-assistant",
60
+ "mcp"
61
+ ],
62
+ "homepage": "https://github.com/PBJ-2/clauderipple",
63
+ "bugs": {
64
+ "url": "https://github.com/PBJ-2/clauderipple/issues"
65
+ }
66
+ }