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.
Files changed (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.ko.md +48 -4
  3. package/README.md +58 -4
  4. package/dist/cli/src/claude-auth.js +3 -2
  5. package/dist/cli/src/codex.js +20 -1
  6. package/dist/cli/src/hooks/agent-title.js +1 -1
  7. package/dist/cli/src/index.js +4 -4
  8. package/dist/cli/src/schtasks.js +43 -1
  9. package/dist/cli/src/settings.js +73 -6
  10. package/dist/router/src/admin.js +489 -56
  11. package/dist/router/src/agents.js +250 -0
  12. package/dist/router/src/bootstrap.js +24 -8
  13. package/dist/router/src/capabilities.js +214 -0
  14. package/dist/router/src/compat.js +5 -1
  15. package/dist/router/src/config.js +264 -11
  16. package/dist/router/src/index.js +14 -1
  17. package/dist/router/src/ingress/server.js +24 -14
  18. package/dist/router/src/picker.js +14 -6
  19. package/dist/router/src/pool.js +233 -0
  20. package/dist/router/src/presets.js +156 -1
  21. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  22. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  23. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  24. package/dist/router/src/providers/chatgpt/index.js +343 -12
  25. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  26. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  27. package/dist/router/src/providers/claude-oauth.js +61 -19
  28. package/dist/router/src/providers/openai/index.js +55 -11
  29. package/dist/router/src/providers/openai/translate.js +82 -14
  30. package/dist/router/src/providers/retry.js +88 -0
  31. package/dist/router/src/proxy.js +697 -82
  32. package/dist/router/src/requestlog.js +5 -2
  33. package/dist/router/src/routing.js +151 -17
  34. package/dist/router/src/version.js +1 -1
  35. package/dist/router/src/websearch.js +307 -0
  36. package/dist/router/src/x509.js +7 -2
  37. package/dist/ui/app.js +740 -160
  38. package/dist/ui/i18n.js +14 -6
  39. package/dist/ui/index.html +18 -5
  40. package/dist/ui/presets-fallback.js +2 -0
  41. package/dist/ui/style.css +133 -9
  42. package/docs/ARCHITECTURE.md +381 -20
  43. package/package.json +5 -1
@@ -1,6 +1,6 @@
1
1
  # ClaudeRipple — Architecture and verified facts
2
2
 
3
- Last verified: 2026-09-11 against Claude Desktop 1.52386.0, Claude Code CLI 2.1.266.
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
- model matches a route rewrite model (+effort), send to provider adapter
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`, then (5) ClaudeRipple's own
267
- `<home>/claude-auth.json` setup-token file. The observed source retains only
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. 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.
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.2.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
  }