clauderipple 0.2.0 → 0.3.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.
- package/CHANGELOG.md +76 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +156 -1
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +697 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ClaudeRipple — Architecture and verified facts
|
|
2
2
|
|
|
3
|
-
Last verified: 2026-09-
|
|
3
|
+
Last verified: 2026-09-20 against Claude Desktop 1.52386.0, Claude Code CLI 2.1.275.
|
|
4
4
|
Everything below is backed by a document, a file, or a measured log. Items marked
|
|
5
5
|
**(assumption)** are not.
|
|
6
6
|
|
|
@@ -12,8 +12,12 @@ Everything below is backed by a document, a file, or a measured log. Items marke
|
|
|
12
12
|
env.NODE_EXTRA_CA_CERTS = <state dir>/ca.pem
|
|
13
13
|
|
|
14
14
|
CLI: CONNECT api.anthropic.com → ClaudeRipple terminates TLS (leaf cert signed by ca.pem)
|
|
15
|
-
per request:
|
|
16
|
-
|
|
15
|
+
per request, in order:
|
|
16
|
+
direct rule (prefix) → send to that provider, model unchanged (+effort)
|
|
17
|
+
slot alias (routes) → rewrite model (+effort), send to provider adapter
|
|
18
|
+
exactly one provider's
|
|
19
|
+
own `models` carries
|
|
20
|
+
the id → send there, model unchanged (see "declared models" below)
|
|
17
21
|
otherwise → forward byte-for-byte to api.anthropic.com over real TLS
|
|
18
22
|
CLI: CONNECT anything else → blind tunnel
|
|
19
23
|
```
|
|
@@ -74,6 +78,19 @@ chat is out of reach for every approach, ours included.
|
|
|
74
78
|
- So: if the picker ever shows `gpt-5.6-sol`, the rest of the chain passes it
|
|
75
79
|
through unchanged. But the picker list travels over claude.ai web traffic in the
|
|
76
80
|
Electron renderer, which a CLI-only proxy cannot see.
|
|
81
|
+
- The CLI is **not** the unchecked link the sentence above might suggest: it does
|
|
82
|
+
classify the id, and an id it does not know produces a stderr line
|
|
83
|
+
`[claude-code:unrecognized_model] {"model":"…","query_source":"…"}`. That
|
|
84
|
+
classification is a **signal only** — the request still goes out with the custom
|
|
85
|
+
id. Measured 2026-09-19 on 2.1.272:
|
|
86
|
+
`claude --model muse-spark-1.3-contributor@medium -p "…"` printed the warning
|
|
87
|
+
and the router logged
|
|
88
|
+
`muse-spark-1.3-contributor@medium -> muse-spark-1.3-contributor out=22`, i.e.
|
|
89
|
+
the id reached the provider unchanged. The same `unrecognized_model` path exists
|
|
90
|
+
in 2.1.268 and 2.1.270, so this is not new behaviour. Two consequences for us:
|
|
91
|
+
picker mode keeps working, but the warning is noise users will report, and the
|
|
92
|
+
classifier is a place where a future release could start rejecting rather than
|
|
93
|
+
warning — worth re-measuring on each CLI bump.
|
|
77
94
|
- **v1 decision: alias mode.** Map existing picker entries (e.g. Opus 4.8 →
|
|
78
95
|
GPT-6 Astra) and make the mapping visible in the ClaudeRipple GUI.
|
|
79
96
|
- **Picker mode (implemented 2026-09-11, `clauderipple picker on`).** Verified in
|
|
@@ -113,6 +130,49 @@ chat is out of reach for every approach, ours included.
|
|
|
113
130
|
|
|
114
131
|
## 4. Provider adapters
|
|
115
132
|
|
|
133
|
+
- **Declared models route themselves (2026-09-19).** A provider's `models` list —
|
|
134
|
+
filled by the probe from the vendor's `/models`, ticked in the GUI — now decides
|
|
135
|
+
routing when no rule matched: a model exactly one provider carries goes there,
|
|
136
|
+
unchanged. Before this, that list was display only, so ticking a model in the GUI
|
|
137
|
+
did nothing until a `direct` rule was also written by hand; one live config had
|
|
138
|
+
eighteen such rules, and seventeen were exactly this case. Rules keep their
|
|
139
|
+
priority, so this only fills the gap they leave — measured against that config,
|
|
140
|
+
**nothing that routed changed** and three models that had been ticked but were
|
|
141
|
+
falling through to Anthropic (`deepseek-flash`, `moonshotai/kimi-k3`,
|
|
142
|
+
`z-ai/glm-5.3-flash`) started working. Two constraints make it safe to have as a
|
|
143
|
+
default: an id **two** providers carry is left to the operator (`deepseek-v4-pro`
|
|
144
|
+
is on both a direct DeepSeek mapping and OpenCode Go, and they are not the same
|
|
145
|
+
deal), and an **ingress-only** owner is not a target — the native `anthropic`
|
|
146
|
+
provider lists the Claude models, and routing those would have done the §5 400 to
|
|
147
|
+
a whole session without anyone writing a rule. An ingress-only owner still counts
|
|
148
|
+
as an owner, so a third party also offering `claude-opus-5` reads as an ambiguity
|
|
149
|
+
to ask about rather than a silent redirection of Claude traffic.
|
|
150
|
+
Prefix rules stay useful for what no provider declares yet (`gpt-` catches a model
|
|
151
|
+
released today). A model reaching a provider this way carries no per-model context
|
|
152
|
+
window unless it is also in `cli.extraModels` or a slot — the same as a direct rule.
|
|
153
|
+
- **The GUI stopped writing a rule per ticked model** at the same time, since ticking
|
|
154
|
+
one is no longer what makes it route. It writes one only for an id more than one
|
|
155
|
+
provider offers, and keeps an existing one for such an id **even when nothing ticks
|
|
156
|
+
it** — `deepseek-v4-pro` is in no picker list and dropping its rule would have
|
|
157
|
+
stopped it routing. Simulating one save on the config above: 18 direct rules → 2
|
|
158
|
+
(`gpt-` and `deepseek-v4-pro`), picker entries unchanged at 5, and **no model
|
|
159
|
+
resolved anywhere different**. Picker exposure stays what the operator ticked; it is
|
|
160
|
+
routing that stopped needing to be said twice.
|
|
161
|
+
- **Worker definitions derive from the same list (2026-09-20).** A ticked model is
|
|
162
|
+
also a subagent the operator can name: for every id exactly one non-ingress
|
|
163
|
+
provider declares, the router writes `~/.claude/agents/<name>.md` (`agents.ts`;
|
|
164
|
+
`name` = the id with anything outside `[a-z0-9-]` folded to `-`, frontmatter
|
|
165
|
+
`model:` = the exact id, `@medium` when the provider offers that level). It
|
|
166
|
+
records what it wrote in `<home>/generated-agents.json` and touches **only**
|
|
167
|
+
those files: a hand-written agent of the same name wins and is left alone, an
|
|
168
|
+
unticked model removes its generated file and nothing else, identical content is
|
|
169
|
+
not rewritten. `cli.agentFiles: false` turns generation off. The marker alias
|
|
170
|
+
table is the union of `aliases` (explicit, wins) and every agent file's
|
|
171
|
+
`name → model` — so `[[ripple: <agent>@<effort>]]` resolves for any agent that
|
|
172
|
+
exists, generated or not, with no second registry to keep in step. Why: on
|
|
173
|
+
2026-09-20 a hand-written agent named `deepseek` had no matching alias; the
|
|
174
|
+
marker `[[ripple: deepseek@high]]` therefore named a model nobody declared, and
|
|
175
|
+
thirty requests went to Anthropic as `PASS` and came back 404 (§5).
|
|
116
176
|
- **Anthropic-compatible providers** (DeepSeek, Kimi/Moonshot, GLM, MiniMax and
|
|
117
177
|
others expose `/v1/messages`): host + model rewrite only, no translation. This
|
|
118
178
|
is the whole reason `claude-code-router` works with just `ANTHROPIC_BASE_URL`.
|
|
@@ -141,7 +201,30 @@ chat is out of reach for every approach, ours included.
|
|
|
141
201
|
multi-turn tool-using session, measured from upstream
|
|
142
202
|
`input_tokens_details.cached_tokens`.**
|
|
143
203
|
- `output_config.effort`: `none…max` accepted by terra/sol/astra, `ultra`
|
|
144
|
-
rejected (luna accepts it). Clamp `ultra` → `max`.
|
|
204
|
+
rejected (luna accepts it). Clamp `ultra` → `max`. **Conflicts with the
|
|
205
|
+
catalogue (2026-09-23):** the Codex model catalogue now lists `ultra` for
|
|
206
|
+
gpt-5.6-terra/sol, gpt-6-astra/sol and *not* for either Luna. Not
|
|
207
|
+
re-measured — the subscription's weekly quota was at 100% that day. The
|
|
208
|
+
global `effortClamp` (`ultra → max`) means no `ultra` is sent either way, so
|
|
209
|
+
only the GUI's ladder follows the catalogue; re-measure before removing the
|
|
210
|
+
clamp. Measured the same day on the OpenAI API side (OpenCode Zen
|
|
211
|
+
`/zen/v1/responses`, not the Codex backend): gpt-6-sol, gpt-6-luna,
|
|
212
|
+
gpt-5.6-sol and gpt-5.6-luna all accept `none, low, medium, high, xhigh,
|
|
213
|
+
max` and answer `400 param: reasoning.effort` to both `minimal` and
|
|
214
|
+
`ultra`. So `ultra` is not a level that API takes on any of them; whether
|
|
215
|
+
the Codex backend does is still open.
|
|
216
|
+
- **Model catalogue (2026-09-23).** `GET {base}/codex/models?client_version=<v>`
|
|
217
|
+
with the same credentials answers `{ models: [{ slug, display_name,
|
|
218
|
+
visibility: "list"|"hide", context_window, supported_reasoning_levels:
|
|
219
|
+
[{effort}] }] }` — what the Codex CLI's picker shows. **The server filters by
|
|
220
|
+
`client_version`:** `0.146.0` omitted gpt-6-astra/sol/luna, `0.155.0`
|
|
221
|
+
listed them. The adapter (`catalog.ts`) asks with the higher of a floor and
|
|
222
|
+
`~/.codex/models_cache.json`'s `client_version`, drops `hide` entries, keeps
|
|
223
|
+
the list an hour, and falls back to a measured list in the same file. The
|
|
224
|
+
GUI's ChatGPT form fetches it on open; new models arrive unticked. Model ids
|
|
225
|
+
unknown to the backend answer `400 "… model is not supported when using
|
|
226
|
+
Codex with a ChatGPT account"` before any quota check, so a `429` for
|
|
227
|
+
gpt-6-sol/luna that day confirmed the ids without spending quota.
|
|
145
228
|
- Model self-introduction is not evidence of routing. Verify by upstream
|
|
146
229
|
usage records.
|
|
147
230
|
- **Implemented 2026-09-11** in `packages/router/src/providers/chatgpt/`.
|
|
@@ -167,7 +250,32 @@ chat is out of reach for every approach, ours included.
|
|
|
167
250
|
response (`x-codex-primary-used-percent`, `-window-minutes`,
|
|
168
251
|
`-reset-after-seconds`, `-reset-at`, `x-codex-plan-type`); the
|
|
169
252
|
`codex.rate_limits` SSE event was not sent in any measured response. The
|
|
170
|
-
adapter reads both.
|
|
253
|
+
adapter reads both. Headers only arrive with traffic, though, and on a quiet
|
|
254
|
+
day the admin status sat at `1%` for eleven hours while the real figure was
|
|
255
|
+
`39%` (2026-09-20). So the adapter also **asks**: `GET
|
|
256
|
+
https://chatgpt.com/backend-api/wham/usage` with the same credentials (the
|
|
257
|
+
Codex CLI's own path — `/api/codex/usage` is a documented alias that answers
|
|
258
|
+
403 to us; measured), a JSON body whose windows are in seconds, mapped to the
|
|
259
|
+
header shape. `/api/status` refreshes when the snapshot is over 10 minutes
|
|
260
|
+
old or on `?refresh=1`, shares one in-flight lookup, and marks the answer
|
|
261
|
+
`stale: true` with a reason when the lookup fails rather than hiding the old
|
|
262
|
+
value. Once at startup too, after `listen()`, never awaited.
|
|
263
|
+
- **The prompt cache is keyed on the conversation's identity, not on
|
|
264
|
+
`prompt_cache_key` (since mid-September 2026).** Five turns with byte-identical
|
|
265
|
+
instructions, tools and input prefix, 3–6s apart under one key, all came back
|
|
266
|
+
`cached_tokens: 0` and `cache_write_tokens: 0` (measured 2026-09-20 on GPT-6
|
|
267
|
+
Astra; the same adapter read 93% on 2026-09-13, 9% that day). The Codex CLI got
|
|
268
|
+
99.8% the same afternoon. Its request, captured through a local reverse proxy
|
|
269
|
+
(`chatgpt_base_url`, websockets off) and bisected against ours: a **stable
|
|
270
|
+
per-conversation id** in `session-id`/`thread-id`, in `x-client-request-id`, or
|
|
271
|
+
in body `client_metadata` turns the cache on — any one of them; `x-codex-turn-
|
|
272
|
+
metadata` alone does not, nor does echoing `x-codex-turn-state` alone, nor a
|
|
273
|
+
UUID-shaped `prompt_cache_key`. The adapter now sends the CLI's set: the four
|
|
274
|
+
headers, `client_metadata` (`session_id`, `thread_id`, fresh `turn_id`,
|
|
275
|
+
`x-codex-window-id`), `prompt_cache_key` as the same UUID, and the previous
|
|
276
|
+
answer's `x-codex-turn-state` echoed back. All derive from `conversationKey`,
|
|
277
|
+
so cache, credential stickiness and identity name one conversation. Measured
|
|
278
|
+
after: turn 2 at 83.6%, turns 3+ at 99.6%.
|
|
171
279
|
- **Tool schema scrub.** The backend validates every `pattern` in
|
|
172
280
|
`tools[].parameters` with a regex engine that rejects lookaround and
|
|
173
281
|
backreferences, and one bad pattern fails the whole request with 400
|
|
@@ -175,9 +283,156 @@ chat is out of reach for every approach, ours included.
|
|
|
175
283
|
`Artifact` tool carries `^(?!__.*__$)…` since ~2.1.266, which broke every
|
|
176
284
|
GPT request through proxenos as well. `normalizeSchema` drops such patterns
|
|
177
285
|
(the client validates its own inputs). Upstream error bodies are now logged.
|
|
286
|
+
- **Tool name constraint.** A Responses function name must match
|
|
287
|
+
`^[a-zA-Z0-9_-]{1,64}$`, and one name that breaks it fails the whole request,
|
|
288
|
+
not just that tool. Claude Code names MCP tools `mcp__<server>__<tool>` and a
|
|
289
|
+
claude.ai connector's server name is a UUID, so the prefix alone eats 43
|
|
290
|
+
characters: over-long names are routine, not exotic (measured in this
|
|
291
|
+
session's own tool list, 2026-09-17; reported as issue #1). The name is
|
|
292
|
+
therefore mangled on every outbound site — tool declarations, `tool_choice`,
|
|
293
|
+
and the `function_call` of a replayed assistant turn — and restored in the
|
|
294
|
+
inbound `function_call` handler, because the model echoes the name it was
|
|
295
|
+
given and Claude Code matches `tool_use.name` against its own tool list.
|
|
296
|
+
`toolNameForResponses` keeps the first 55 characters of the sanitised name
|
|
297
|
+
and appends `_` plus 8 hex of sha256(original); every mangled name carries
|
|
298
|
+
the hash, so two names that sanitise alike (`a.b`, `a-b`) cannot collide.
|
|
299
|
+
A name already inside the constraint is returned untouched, so a session
|
|
300
|
+
without MCP tools produces byte-identical output and the cache prefix does
|
|
301
|
+
not move. The same constraint and the same helpers apply to the
|
|
302
|
+
`openai-compatible` adapter (§4c).
|
|
303
|
+
- **Server tools are dropped, on every translated path.** `web_search` and its siblings arrive
|
|
304
|
+
as `{ type: "web_search_20250305", name: "web_search", max_uses: N }` with no `input_schema`.
|
|
305
|
+
They are run by Anthropic, not by the model holding them, so declaring one to a translated
|
|
306
|
+
provider offers a tool that cannot execute: the model calls it, nothing answers, and the turn
|
|
307
|
+
returns empty with no error. `compat.ts` has dropped these on the anthropic-compatible path
|
|
308
|
+
from the start; the ChatGPT and openai-compatible translators do the same, and a `tool_choice`
|
|
309
|
+
that named a dropped tool is dropped with it. The adapter logs each drop by name, because the
|
|
310
|
+
failure it replaces is silent.
|
|
311
|
+
- **Where web search actually runs (measured 2026-09-17).** Claude Code does not put `web_search`
|
|
312
|
+
in the main request. `WebSearch` opens a *separate side request* — system prompt "You are an
|
|
313
|
+
assistant for performing a web search tool use", one message "Perform a web search for the
|
|
314
|
+
query: …", the server tool forced by `tool_choice`, `max_uses: 8` — and sends it to
|
|
315
|
+
`H("tengu_plum_vx3") ? Og() : mainLoopModel()`. That gate is on: an Opus 5 session and a
|
|
316
|
+
DeepSeek-routed session both sent it to `claude-haiku-4-5-20251001`, byte-for-byte the same
|
|
317
|
+
request (11,208 input tokens for an identical query), which passes through to Anthropic and
|
|
318
|
+
never reaches an adapter. A control run without a search produced no such request. So today a
|
|
319
|
+
routed model never sees a server tool — **and the model driving the search is Haiku for every
|
|
320
|
+
session, Claude or routed.** The routed model still chooses the query and reads the results;
|
|
321
|
+
only titles and URLs survive the hand-back (snippets are dropped in the CLI), plus the search
|
|
322
|
+
model's prose. The gate is Anthropic's to flip, which is why the drop above exists.
|
|
323
|
+
- **Some providers run the search themselves — ask them, do not read about them.** DeepSeek's
|
|
324
|
+
Anthropic endpoint executes `web_search_20250305` server-side. Its API documentation does not
|
|
325
|
+
say so, and a docs check concluded twice that it could not; sending the tool to
|
|
326
|
+
`api.deepseek.com/anthropic` returned `server_tool_use`, a `web_search_tool_result` holding ten
|
|
327
|
+
hits, and `usage.server_tool_use.web_search_requests: 1` (2026-09-17). The blanket server-tool
|
|
328
|
+
drop was therefore destroying a capability the user was already paying for, invisibly. The drop
|
|
329
|
+
is now gated on `caps.serverTools`, set per preset from a measured reply only.
|
|
330
|
+
- With that, and `ANTHROPIC_SMALL_FAST_MODEL` pointed at the routed model so the side request
|
|
331
|
+
reaches the provider at all, a whole `deepseek-flash` session — search included — ran with
|
|
332
|
+
**zero** Anthropic calls of any kind, title generation included. No interception, no second
|
|
333
|
+
vendor, no translation: the provider the user pays for does its own search.
|
|
334
|
+
- **Model slots (`cli.models`).** Claude Code fixes its own model choices before a request
|
|
335
|
+
exists, so routing cannot reach them; they are environment names written into
|
|
336
|
+
`~/.claude/settings.json` on install, alongside the proxy entries:
|
|
337
|
+
`main` → `ANTHROPIC_MODEL`, `smallFast` → `ANTHROPIC_SMALL_FAST_MODEL`,
|
|
338
|
+
`subagent` → `CLAUDE_CODE_SUBAGENT_MODEL`. Each is left alone when unset, so nothing changes
|
|
339
|
+
until one is chosen, and `removeProxyEnv` always takes all three back — a slot still pointing
|
|
340
|
+
at a routed model after uninstall would send every search and subagent to a router that is no
|
|
341
|
+
longer there. The GUI offers them on the Clients screen rather than leaving them to
|
|
342
|
+
`config.json`, because they are the difference between a session that costs Claude quota and
|
|
343
|
+
one that does not.
|
|
344
|
+
- **A GUI save writes them too.** `PUT /api/config` writes only `config.json` — the router
|
|
345
|
+
hot-reloads that on mtime — but nothing about the router can apply a slot, since the CLI
|
|
346
|
+
reads it before a request exists. So the handler calls `syncModelSlots` and returns a
|
|
347
|
+
`warning` the GUI has to show. Writing them on install alone was the bug (2026-09-21): the
|
|
348
|
+
Clients screen saved `smallFast: deepseek-v4.1-flash` into `config.json`, the screen showed
|
|
349
|
+
DeepSeek, and every search, title and classifier went on running on Haiku because
|
|
350
|
+
`env.ANTHROPIC_SMALL_FAST_MODEL` was never written. `syncModelSlots` is additive — a slot
|
|
351
|
+
the config does not name is left as it is, so a GUI save cannot undo a hand-set one — and it
|
|
352
|
+
refuses to write when `env.HTTPS_PROXY` belongs to another proxy.
|
|
353
|
+
- **The screen says which models the router cannot search through** (2026-09-21). A slot
|
|
354
|
+
pointed at such a model makes every `WebSearch` a visible failure, and nothing on the screen
|
|
355
|
+
warned. `GET /api/status` marks each provider with `webSearch: true` when the router could run
|
|
356
|
+
a search there, and the Clients screen appends "no web search here" to every `smallFast`
|
|
357
|
+
option without it. The flag answers *can the router search here*, not *can this model
|
|
358
|
+
search* — those differ, and the difference was measured: OpenCode Go's **Responses** endpoint
|
|
359
|
+
runs OpenAI's hosted `web_search` and cites real pages, but its wire is OpenAI-shaped while
|
|
360
|
+
its hits arrive as Responses events (`web_search_call`, `url_citation`), a backend the router
|
|
361
|
+
does not have. DeepSeek's own endpoint and the ChatGPT provider run Anthropic's server tool;
|
|
362
|
+
OpenCode Go's chat and Anthropic endpoints, and every `openai-compatible` provider, do not.
|
|
363
|
+
- **A model that cannot search does not merely decline — it invents.** Measured 2026-09-21
|
|
364
|
+
against OpenCode Go's chat endpoint: `plugins: [{id: "web"}]` is accepted and ignored (HTTP
|
|
365
|
+
200, no `annotations`, cache reporting no lookup), and the model answers a search-shaped
|
|
366
|
+
request with a confident answer built from plausible URLs. Pointed at an obscure query it
|
|
367
|
+
admits it ("There's no tool for web search provided"); pointed at a common one it does not.
|
|
368
|
+
This is why the router refuses such a side request instead of forwarding it — an answer shaped
|
|
369
|
+
like success, holding nothing, is worse than a visible failure.
|
|
370
|
+
- This is the preferred path wherever it works. `cfg.webSearch` below is the fallback for
|
|
371
|
+
providers that cannot, and for the translated paths, where the tool is not passed through but
|
|
372
|
+
converted.
|
|
373
|
+
- **Serving the search ourselves (`cfg.webSearch`, 2026-09-17).** Because the search runs on a
|
|
374
|
+
Claude model, a routed session still cannot search without Claude quota — the product is only
|
|
375
|
+
half routed. With `webSearch: { provider, model }` set, the router recognises the side request
|
|
376
|
+
and answers it from that provider's own hosted search, and no model call leaves for Anthropic.
|
|
377
|
+
Verified end to end: a `deepseek-flash` session searched and got real results with **zero**
|
|
378
|
+
Anthropic `/v1/messages` calls in the log, the only `PASS` line being the intercepted search
|
|
379
|
+
itself. It needs no env var — we terminate TLS for `api.anthropic.com`, so the side request
|
|
380
|
+
passes through whatever its model id, and the interception sits ahead of routing.
|
|
381
|
+
- The fingerprint is **one declared tool, the `web_search` server tool, and one user message
|
|
382
|
+
that is the CLI's fixed sentence**. A forced `tool_choice` is deliberately *not* required:
|
|
383
|
+
the CLI source passes `toolChoice: {type:"tool", name:"web_search"}`, but the wire carries
|
|
384
|
+
`{"type":"auto"}` (measured). Reading the source alone got this wrong once.
|
|
385
|
+
- OpenRouter runs it as `plugins: [{id:"web"}]` on Chat Completions and answers with
|
|
386
|
+
`annotations[].url_citation` (`title`, `url`); the side request's `allowed_domains` /
|
|
387
|
+
`blocked_domains` map to `include_domains` / `exclude_domains`. Measured live: four
|
|
388
|
+
citations, $0.0073 for the call.
|
|
389
|
+
- **ChatGPT subscription search (implemented and live-verified 2026-09-20).** A `chatgpt`
|
|
390
|
+
provider uses the same `CredentialStore` and Codex Responses endpoint as ordinary turns,
|
|
391
|
+
with `tools: [{type:"web_search", search_context_size:"low",
|
|
392
|
+
external_web_access:true}]` and `tool_choice:"required"`. The measured stream carries a
|
|
393
|
+
`web_search_call`, `url_citation` annotations, and
|
|
394
|
+
`response.completed.response.tool_usage.web_search.num_requests`. The adapter requires both
|
|
395
|
+
a real search count and at least one citation; it never turns uncited prose into a successful
|
|
396
|
+
result. `allowed_domains` becomes `filters.allowed_domains`. Codex exposes no corresponding
|
|
397
|
+
exclusion filter, so `blocked_domains` is refused rather than ignored. This path is opt-in
|
|
398
|
+
through `webSearch: {provider, model}`; with no setting, Claude Code's existing Haiku search
|
|
399
|
+
path is unchanged.
|
|
400
|
+
- The reply is assembled as `server_tool_use` + `web_search_tool_result` + `text`, with
|
|
401
|
+
`usage.server_tool_use.web_search_requests` — the field the CLI turns into "Did N searches".
|
|
402
|
+
- A provider that runs the tool itself is asked in Anthropic's own shape instead
|
|
403
|
+
(`anthropicServerToolBackend`): the same side request, relayed, with the hits read back out
|
|
404
|
+
of its `web_search_tool_result`. No translation, no agent loop — opencodex needs a loop only
|
|
405
|
+
because its backends cannot be handed a server tool directly (§4 of OPENCODEX).
|
|
406
|
+
- **A side request routed where the tool cannot run is refused, not sent.** Recognising the
|
|
407
|
+
search does not depend on `webSearch` being configured — it is the unconfigured router that
|
|
408
|
+
most needs the guard. The reply is `web_search_tool_result_error: unavailable` with
|
|
409
|
+
`web_search_requests: 0`, so the CLI reports a failed search instead of printing invented
|
|
410
|
+
prose. This was found the hard way: a stale `ANTHROPIC_SMALL_FAST_MODEL` left in a running
|
|
411
|
+
session kept sending searches to a provider that could not run them, and the session reported
|
|
412
|
+
the search tool as unresponsive with nothing in any log to say why.
|
|
413
|
+
- **The backend is chosen by how the provider is spoken to, not by vendor.**
|
|
414
|
+
`anthropic-compatible` → server tool, `openai-compatible` → web plugin. Anything else refuses
|
|
415
|
+
and leaves the request alone.
|
|
416
|
+
- An `anthropic-compatible` provider whose `serverTools` capability is false is **refused before
|
|
417
|
+
the request is sent**. This is not caution: dropping the server tool leaves a request that
|
|
418
|
+
says "you are an assistant for performing a web search tool use / perform a web search for the
|
|
419
|
+
query: …" **with no tool attached**, and a model told to search with nothing to search with
|
|
420
|
+
narrates a tool call instead. OpenCode Go's DeepSeek answered exactly that — its own
|
|
421
|
+
`<||DSML|| invoke name="web_search">` markup as plain text, zero searches, HTTP 200
|
|
422
|
+
(measured 2026-09-18). Same vendor as the DeepSeek that does run it; different route.
|
|
423
|
+
- A backend that returns no citations, or no result blocks, **throws**, and the request falls
|
|
424
|
+
through to the ordinary path. A search that quietly returns nothing is the one outcome worth
|
|
425
|
+
avoiding, since nothing anywhere reports it.
|
|
426
|
+
- The search model iterates: a single `WebSearch` can produce several side requests with
|
|
427
|
+
refined queries, each intercepted on its own.
|
|
428
|
+
- `WebFetch` needs none of this: the CLI fetches the URL itself (its own transport, cache and
|
|
429
|
+
preflight; there is no `web_fetch` server tool in the binary) and has the session's model read
|
|
430
|
+
the text. It works on any provider.
|
|
178
431
|
- Cache-safety decisions: thinking blocks are dropped from replayed history;
|
|
179
432
|
no reasoning `include`; identity line and `instructionsAppend` are constant
|
|
180
|
-
text; `prompt_cache_key` = sha256(metadata.user_id + first user message)
|
|
433
|
+
text; `prompt_cache_key` = sha256(metadata.user_id + first user message),
|
|
434
|
+
and sha256(system prompt) for a request that carries no `metadata.user_id`
|
|
435
|
+
and one lone user turn — see the conversation-key note in §4.
|
|
181
436
|
|
|
182
437
|
### 4c. What a routed model is told it is
|
|
183
438
|
|
|
@@ -224,6 +479,59 @@ chat is out of reach for every approach, ours included.
|
|
|
224
479
|
(char/4, floored by the last measured total for the conversation) so the app's
|
|
225
480
|
token counter and the CLI's context accounting are not zero.
|
|
226
481
|
|
|
482
|
+
- **Credential pools (`providers.<name>.credentials`, 2026-09-17).** A provider may hold several
|
|
483
|
+
credentials, each a complete header set. The conversation keeps the one it is on while that one
|
|
484
|
+
is healthy — moving it moves the prompt cache with it, and ≥90% cache hit is an acceptance metric
|
|
485
|
+
— and only a failure moves it, including back to one that has recovered rather than to the head
|
|
486
|
+
of the list. A 401 quarantines a credential, because rejected credentials do not heal; a 429
|
|
487
|
+
cools it until the vendor's stated reset (`retry-after`, seconds or HTTP-date, or the Codex reset
|
|
488
|
+
header), clamped to six hours; a 402 cools it for half an hour; a 5xx or a connect failure for
|
|
489
|
+
ten seconds. **A 4xx that is the request's own fault is charged to nobody** — retrying a bad body
|
|
490
|
+
against every credential burns the pool and still fails. State is in memory: a cooldown that
|
|
491
|
+
outlived a restart would make restarting worse. A provider that declares no pool has exactly the
|
|
492
|
+
credential it always had. A slot may also name `fallbacks`: when every credential of its provider
|
|
493
|
+
is parked, the turn goes to the first fallback that has one, chosen **before anything is sent**,
|
|
494
|
+
since failing over mid-turn would splice two answers together. With nothing usable anywhere the
|
|
495
|
+
primary is kept, so the provider refuses rather than the router inventing a refusal.
|
|
496
|
+
- Four things an independent review found, each of which had made it past the unit tests because
|
|
497
|
+
each lived in the wiring rather than the state machine: the pool never heard about the ChatGPT
|
|
498
|
+
or openai-compatible adapters, so those providers looked healthy forever and a slot pointing at
|
|
499
|
+
one could never fail over; an empty header set went upstream when the pool had nothing ready,
|
|
500
|
+
and the resulting 401 quarantined a credential that had done nothing; `upReq.destroy()` emits
|
|
501
|
+
ECONNRESET (measured, Node 24.15), so **every cancelled turn was charged to the credential** and
|
|
502
|
+
moved the conversation off it, costing the prompt cache the metric depends on; and a fallback
|
|
503
|
+
naming a native `anthropic` provider re-opened the §5 row about ingress-only targets.
|
|
504
|
+
- 403 does not quarantine. It is also a content policy, a blocked region, a model the account may
|
|
505
|
+
not use, or an edge refusing what it took for a bot — parking a working credential until someone
|
|
506
|
+
notices is the worse mistake. Only 401 parks.
|
|
507
|
+
- An answer lifts a quarantine but does not cancel a live cooldown: requests overlap, and a 200
|
|
508
|
+
arriving after a concurrent 429 does not mean the limit lifted.
|
|
509
|
+
- The conversation key is `conversationKey`, the same one the prompt cache uses. `metadata.user_id`
|
|
510
|
+
alone is one value for every conversation a user has, so using it raw dragged all of them onto
|
|
511
|
+
one credential at once — the opposite of what stickiness is for.
|
|
512
|
+
- The same key has a second branch, because not every request is a conversation. What the CLI
|
|
513
|
+
sends *beside* one — the web-search side request, a title, a summary — carries no `metadata` at
|
|
514
|
+
all and one lone user message that is different every time, so seeding on that message minted a
|
|
515
|
+
new key per request: measured `cached_tokens: 0` on all 68 smallFast calls in a day's log, while
|
|
516
|
+
Muse hit 97/129 and Opus 6,960/11,633 in the same log. It was not the endpoint — the same wire
|
|
517
|
+
asked twice under one key returned 94% (2,304/2,441). So a request with no `user_id` and one
|
|
518
|
+
message is keyed on its **system prompt**, which is the part of it that does not vary: every
|
|
519
|
+
web search shares one key, every title another, and the fixed prefix each class sends is cached
|
|
520
|
+
after the first. This is the acceptance metric (≥90% on translated providers), not a tidy-up.
|
|
521
|
+
A real conversation that was merely never given metadata — the OpenAI ingress builds none — is
|
|
522
|
+
keyed as before from its second turn on; only its opening turn, which is indistinguishable from
|
|
523
|
+
a side request, joins the class key, and what it reads there is the prefix it sends anyway.
|
|
524
|
+
- **A refused credential is replaced inside the same turn**, while nothing has been written to the
|
|
525
|
+
client. The turn that discovers a limit used to be spent — the client got the 429 and retried it
|
|
526
|
+
itself — and now the next credential answers on the first ask. Only before the first byte: after
|
|
527
|
+
that the turn is committed, because replacing a half-sent stream splices two answers together.
|
|
528
|
+
A credential is tried at most once per turn, and a failure that is the request's own fault is
|
|
529
|
+
not retried at all, so a bad body cannot walk the whole pool.
|
|
530
|
+
- Covered by `test/proxy-failover.test.ts`, which drives a real proxy rather than the state
|
|
531
|
+
machine: rotation, the exhausted-pool header, the cancelled turn, failover, the ingress-only
|
|
532
|
+
refusal, the same-turn retry, its stopping condition, and a 400 not being retried. Three were
|
|
533
|
+
written before the code and confirmed red first; two more were confirmed by breaking the fix
|
|
534
|
+
again afterwards.
|
|
227
535
|
- **Provider base path.** Anthropic-compatible vendors mount the API under a path
|
|
228
536
|
(`https://api.deepseek.com/anthropic`, `https://openrouter.ai/api`,
|
|
229
537
|
`https://dashscope-intl.aliyuncs.com/apps/anthropic`); the router prepends it to
|
|
@@ -263,8 +571,9 @@ chat is out of reach for every approach, ours included.
|
|
|
263
571
|
`api.anthropic.com/v1/messages` request (expires after 12 hours and vanishes
|
|
264
572
|
on router restart), (2) `CLAUDE_CODE_OAUTH_TOKEN`, (3) Claude Code Keychain
|
|
265
573
|
service `Claude Code-credentials`.claudeAiOauth, (4)
|
|
266
|
-
`~/.claude/.credentials.json`,
|
|
267
|
-
`<home>/claude-auth.json` setup-token file
|
|
574
|
+
`~/.claude/.credentials.json`, (5) ClaudeRipple's own
|
|
575
|
+
`<home>/claude-auth.json` setup-token file, then (6) OAuth accounts added to
|
|
576
|
+
`<home>/claude-accounts.json`. The observed source retains only
|
|
268
577
|
`Authorization`, `anthropic-version`, `anthropic-beta`, `user-agent`,
|
|
269
578
|
`x-app`, `x-stainless-*`, and `anthropic-client-*` request headers and sends
|
|
270
579
|
that exact set upstream. It is never persisted, logged, included in RequestLog
|
|
@@ -286,17 +595,38 @@ chat is out of reach for every approach, ours included.
|
|
|
286
595
|
`https://platform.claude.com/oauth/code/callback` and the user pastes
|
|
287
596
|
`code#state` (or the redirect URL). State and PKCE verifier are per attempt; a callback
|
|
288
597
|
with the wrong state is refused without ending the attempt; an attempt
|
|
289
|
-
expires after 5 minutes.
|
|
290
|
-
only in `<home>/claude-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
and `POST /api/claude-oauth/code`;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
598
|
+
expires after 5 minutes. Each grant (access + refresh token, expiry) is stored
|
|
599
|
+
only in `<home>/claude-accounts.json` (mode `0600`). The upstream account UUID
|
|
600
|
+
is hashed before storage and used only to replace the same account on re-login;
|
|
601
|
+
admin surfaces expose a local opaque id, label, optional email and health, never
|
|
602
|
+
either token or the upstream UUID. The GUI drives the same flow through
|
|
603
|
+
`POST/GET /api/claude-oauth` and `POST /api/claude-oauth/code`; another sign-in
|
|
604
|
+
adds an account rather than overwriting an unrelated one. An existing OAuth
|
|
605
|
+
grant in the old `<home>/claude-auth.json` remains readable and is migrated
|
|
606
|
+
atomically on the first pool mutation. `--setup-token` keeps that old file for
|
|
607
|
+
its terminal-only long-lived token path; `claude-logout` removes both stores.
|
|
608
|
+
Wire facts are behaviorally measured (the reference implementations do the
|
|
609
|
+
same flow); they are not an Anthropic guarantee, and reuse of one or multiple
|
|
610
|
+
subscriptions is subject to Anthropic's terms and possible account restrictions.
|
|
611
|
+
- A native `anthropic` provider remains OpenAI-ingress-only unless it explicitly
|
|
612
|
+
sets `accountPool: true`. With that flag and `auth: "claude-code"`, Claude
|
|
613
|
+
Desktop/Code Messages requests stay unmodified except for the routed model and
|
|
614
|
+
selected identity. The runtime pool is ordered current observed/env/Claude Code
|
|
615
|
+
login first, then ClaudeRipple-owned accounts. A conversation is sticky to the
|
|
616
|
+
account that answered so its prompt-cache prefix survives, including across that
|
|
617
|
+
account's token refresh. Before response headers reach the client, 401, 429, 402
|
|
618
|
+
and retryable HTTP failures such as 5xx may retry the same request on an untried
|
|
619
|
+
account; request-shape errors such as 400 do not, and no failover occurs after a
|
|
620
|
+
response starts. A 401 permanently marks only that stored token generation as
|
|
621
|
+
needing re-login; 429 and transient failures cool down without deleting it. Refresh
|
|
622
|
+
is per-account process-wide single-flight and persisted with compare-and-swap so a
|
|
623
|
+
stale refresh or rejection cannot overwrite a newer sign-in. Credential health uses
|
|
624
|
+
a token-generation runtime id while affinity and admin APIs use the durable local
|
|
625
|
+
account id, so a fresh login cannot inherit an old quarantine or lose its cache claim.
|
|
626
|
+
Retry replaces every previous identity header, and every sent
|
|
627
|
+
secret is added to error redaction. The OpenAI ingress for Codex uses the same
|
|
628
|
+
ordered projection only to choose one available credential; it deliberately does
|
|
629
|
+
not rotate accounts or retry a turn.
|
|
300
630
|
- Readiness vs liveness (0.1.2): `/api/status.readiness` and `GET /readyz`
|
|
301
631
|
(200 or 503 + `retry-after: 5`) list what stands between a request and a
|
|
302
632
|
model: `settings` (Claude Code not pointed at us), `upstream` (consecutive
|
|
@@ -356,7 +686,9 @@ Sources: [Codex configuration reference](https://learn.chatgpt.com/docs/config-f
|
|
|
356
686
|
[Codex advanced configuration](https://learn.chatgpt.com/docs/config-file/config-advanced),
|
|
357
687
|
[Claude Code authentication and credential management](https://code.claude.com/docs/en/authentication),
|
|
358
688
|
and the behavior-only [OpenCodex source](https://github.com/lidge-jun/opencodex)
|
|
359
|
-
inspected 2026-09-13. The latter is not an Anthropic guarantee.
|
|
689
|
+
inspected 2026-09-13. The latter is not an Anthropic guarantee. Its published documentation is
|
|
690
|
+
surveyed in [OPENCODEX.md](OPENCODEX.md) — adapter wire behaviour, the patterns worth reusing, and
|
|
691
|
+
what we do not have yet — so that adding a provider does not start with reading it again.
|
|
360
692
|
|
|
361
693
|
### 4c. `openai-compatible` providers (implemented 2026-09-13)
|
|
362
694
|
|
|
@@ -365,6 +697,23 @@ inspected 2026-09-13. The latter is not an Anthropic guarantee.
|
|
|
365
697
|
then maps SSE back into Anthropic `message_start`, content-block, `message_delta`,
|
|
366
698
|
and `message_stop` events. It supports text, base64/URL images, function tools,
|
|
367
699
|
tool results, non-streaming replies, and local `count_tokens` estimates.
|
|
700
|
+
- **A model may speak another wire than the rest of its provider.** One subscription can
|
|
701
|
+
serve several protocols on one key and one catalog: OpenCode Go answers Responses,
|
|
702
|
+
Chat Completions and Anthropic Messages models on the same plan, and its `/models`
|
|
703
|
+
lists them together (measured 2026-09-18). `wire` and `url` sat only on the provider,
|
|
704
|
+
so that one account had to be configured as three providers — a split the user saw in
|
|
705
|
+
the GUI and had to repeat in every `direct` rule. A model entry now overrides `wire`
|
|
706
|
+
(`chat`, `responses` or `anthropic`), `url` and `authHeader`, and `providerFor` in
|
|
707
|
+
`config.ts` folds those into an ordinary provider at the moment a request's model is
|
|
708
|
+
known, so every `provider.type` branch downstream works against one unchanged
|
|
709
|
+
contract. A provider with no override for that model is returned by identity.
|
|
710
|
+
`authHeader` names a convention rather than restating the key: the provider's own
|
|
711
|
+
credential is re-sent under the header that model's wire expects, so there is one copy
|
|
712
|
+
of the key to rotate. Each endpoint follows the API it imitates — an Anthropic-wire
|
|
713
|
+
model wants `x-api-key` where an OpenAI-wire one on the same key wants a bearer. A
|
|
714
|
+
config still written as three providers is folded on the way in and its `direct` and
|
|
715
|
+
`routes` references follow; nothing is rewritten on disk, so it stays readable by an
|
|
716
|
+
older router until the GUI saves it.
|
|
368
717
|
- Translation deliberately strips `thinking`, `context_management`, `thread`,
|
|
369
718
|
`diagnostics`, and `container`; `thread:continue` is refused exactly as in §4a so
|
|
370
719
|
the CLI resends a full stateless history. The per-turn
|
|
@@ -402,7 +751,10 @@ inspected 2026-09-13. The latter is not an Anthropic guarantee.
|
|
|
402
751
|
| `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
752
|
| 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
753
|
| 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. |
|
|
754
|
+
| Tool names over 64 characters were forwarded to the Responses API unchanged, so a single connected MCP server — the product's own use case (§2) — failed every request of that turn (found by reading the source, issue #1, 2026-09-17) | Any name a translated provider sends is mangled into the provider's constraint deterministically and restored on the way back (§4). A translator must validate what it forwards against the wire it forwards to, not only what it builds itself. |
|
|
405
755
|
| 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. |
|
|
756
|
+
| Server tools (`web_search`) were declared to translated providers as ordinary functions: a tool the model can call and nothing can run. Not observed live, because Claude Code currently routes its web search elsewhere (§4) — the failure was one server-side flag away, and its shape is an empty answer with nothing logged (issue #4, 2026-09-17) | A translator declares only what the provider can actually execute, and says in the log what it removed. A capability that silently disappears is worse than one that visibly fails. |
|
|
757
|
+
| One context window was written to every routed model and every slot, because `cli.autoCompactWindow` is a single number while `auto_compact_windows` and `context_window_by_model` are per-model maps. Routed models do not share a window: set it high and the smaller model overflows before it compacts, set it low and the larger throws away most of its own (issue #2, 2026-09-17) | A window belongs to a model, not to the router. `CliModel.contextWindow` and `Route.contextWindow` win, then whatever the vendor's `/models` reported as `context_length`, then the global value as the fallback it always was. A value the config can only express once must not be injected into a map that is keyed per model. |
|
|
406
758
|
| 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
759
|
| 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
760
|
| "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. |
|
|
@@ -421,6 +773,15 @@ inspected 2026-09-13. The latter is not an Anthropic guarantee.
|
|
|
421
773
|
| 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
774
|
| "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
775
|
| 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. |
|
|
776
|
+
| A subagent prompt carried `[[ripple: deepseek@high]]` while `aliases` had no `deepseek`; the marker resolved to a model id nobody declared, `resolve()` returned null, and the request went to Anthropic as an ordinary `PASS` — 30 × `404 model: deepseek-v4.1-flash` over two days, read by the session as "the model stopped working" (2026-09-19/20) | A non-`claude-*` model this router cannot route is **refused here, by name**: `400 invalid_request_error "ClaudeRipple: <reason>"`, tag `REFUSE`, reason in the request record (`unroutableReason`: undeclared / declared by two providers / ingress-only owner / unknown marker alias / alias to an undeclared model). Native `claude-*` ids keep passing through untouched — Claude traffic is never hijacked to say no. And the marker alias table is derived from the agent files themselves (§4), so an agent that exists is an alias that resolves. |
|
|
777
|
+
| A rejected Claude account remained quarantined after its access/refresh token was replaced, because runtime health used the durable account id; keying affinity on that same generation id would instead move a healthy conversation after every refresh (found during multi-account implementation, 2026-09-20) | Runtime health keys on `<local-owner>:<token-generation-digest>`, while conversation affinity and admin state key on the durable local owner. A refresh or re-login receives fresh health without losing its prompt-cache claim; compare-and-swap prevents an old rejection from marking the new generation. |
|
|
778
|
+
| The worker registry was four places that did not know each other — `providers.*.models`, `aliases`, `~/.claude/agents/*.md`, and prose in CLAUDE.md — and every new provider needed all four edited by hand; the one left out was the one that failed (2026-09-19, DeepSeek) | One source: a ticked model *is* a worker. Agent files and marker aliases are generated from `config.json` (§4 "worker definitions"); nothing about a worker is written twice. |
|
|
779
|
+
| ChatGPT prompt-cache hit fell from 93% (2026-09-13) to 9% (2026-09-20) with nothing in our request changing — the backend had started keying the cache on the conversation's identity (`session-id`/`thread-id`/`x-client-request-id`/`client_metadata`), which we never sent; `prompt_cache_key` alone no longer earned a write | The adapter states the conversation's identity the way the Codex CLI does (§4) and echoes `x-codex-turn-state`. The acceptance metric (≥90% on translated providers) is watched per day in `requests.jsonl`; a fall with an unchanged request means the wire changed under us, and the reference to diff against is the real CLI captured through a local proxy, not our own memory of the protocol. |
|
|
780
|
+
| Remote Control registration failed locally with 405 before Anthropic saw it: Claude Code 2.1.275 uses HTTPS absolute-form (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`) rather than CONNECT for registration, polling and heartbeats, while the forward-proxy socket accepted CONNECT only (issue #10, Windows 11) | Accept valid HTTPS absolute-form, strip proxy-only headers, replace Host, rewrite only the target to origin-form, and relay one request over direct TLS outside model routing. Force `Connection: close`: reusing that TLS socket could send a later proxy-form request to the first origin. Parser tests plus a live local TLS origin verify body and credential preservation. |
|
|
781
|
+
| Re-running `clauderipple install` as a normal Windows user rewrote the launcher, then `Register-ScheduledTask -Force` could answer `0x80070005 Access is denied` even though the existing per-user task was already correct (issue #9) | Reuse an existing task only when its entire intended definition matches: one PowerShell action and exact arguments, current user, limited interactive logon, one matching logon trigger, restart count/interval, no execution limit, IgnoreNew, and battery policy. A missing, stale or foreign same-named task still takes the normal registration/error path. |
|
|
782
|
+
| OpenCode Go's relay answered `403 {"message":"Error from provider (Console Go): Upstream request failed: …"}` during a few seconds of its own trouble. The 403 was classified as an auth failure, which both cooled the credential for 60s **and** removed it from the retry candidate list, so the router could not retry inside the turn and answered the user with what read as an authentication error. Five times in a day, each cleared by asking again (2026-09-21) | A 403 whose body reports an upstream failure is charged a **zero** cooldown. It is not a statement about the credential, and any wait at all moves the conversation off a key that did nothing and takes its prompt cache with it (§4). With no cooldown the credential stays retryable, so the turn is answered on the next attempt instead of becoming a user-visible failure. The body is the only way to tell this apart from a real 403 (policy, region, blocked model), so it is read for 403 and only 403. |
|
|
783
|
+
| The same relay 403 kept reaching the user after that fix, because it was the wrong layer: DeepSeek runs on `openai-compatible`, whose adapter was **never** retried at all — the in-turn retry lives only in the anthropic-compatible wire path (`nextCredential`), and `credentialsOf` gives a translated provider exactly one credential and no fallback. So a relay hiccup had nowhere to go but the user (2026-09-21, root cause of the day's repeated "authentication failed") | Every translated adapter retries the `fetch` **before** any status or byte is written, so the turn is still replaceable (§4 "the first byte commits the turn"). `fetchWithRetry` retries only `transient` verdicts — connect failures, 5xx, and a 403 reporting a broken upstream — to the ceiling of 10 attempts the CLI itself uses, with a 250ms→2s backoff. A 401/429 is **not** retried: the adapter holds one credential, so asking the key that just refused the identical request again only delays the error. `classify` is reused for the judgement, with `kind === "transient"` narrowing a pool's answer to an adapter's. |
|
|
784
|
+
| Workers on muse-spark-1.3 "stalled for minutes, then died": 84 of 1,815 turns (2026-09-18..22) waited a median 58s — five exactly 300s — and then the upstream closed the stream with **no** `response.completed`, no usage, no text. Both translated adapters finished such a stream as `end_turn` with whatever had arrived (the ChatGPT one by a comment saying so), so Claude Code received an empty, successful turn, did not retry, and the subagent ended with nothing. Same shape on `chatgpt` (gpt-6-astra, 12 turns, median 105s) and DeepSeek. A non-streaming failure was likewise answered 200 with the fragment | A turn is finished only when the vendor says so: `response.completed`/`response.incomplete` on Responses, a `finish_reason` or `[DONE]` on Chat. A stream that closes without one, or breaks mid-read, ends in an SSE `overloaded_error` — the one mid-stream error Claude Code retries on its own (CLI 2.1.278 matches `"type":"overloaded_error"` in the message; `api_error` is final). Non-streaming turns answer 529 with the same error, and the request record says the turn failed. The upstream stall itself is the vendor's; what must not happen here is turning it into an answer. |
|
|
424
785
|
|
|
425
786
|
## 6. Blocked paths (measured, do not retry)
|
|
426
787
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"workspaces": [
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^24.3.0",
|
|
24
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
24
25
|
"typescript": "^5.9.2"
|
|
25
26
|
},
|
|
26
27
|
"type": "module",
|
|
@@ -62,5 +63,8 @@
|
|
|
62
63
|
"homepage": "https://github.com/PBJ-2/clauderipple",
|
|
63
64
|
"bugs": {
|
|
64
65
|
"url": "https://github.com/PBJ-2/clauderipple/issues"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"proper-lockfile": "^4.1.2"
|
|
65
69
|
}
|
|
66
70
|
}
|