clauderipple 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.ko.md +96 -25
- package/README.md +115 -28
- package/dist/cli/src/browser.js +3 -2
- package/dist/cli/src/claude-auth.js +6 -0
- package/dist/cli/src/index.js +36 -23
- package/dist/cli/src/picker.js +99 -6
- package/dist/cli/src/schtasks.js +48 -19
- package/dist/cli/src/settings.js +10 -3
- package/dist/cli/src/supervisor.js +17 -11
- package/dist/cli/src/systemd.js +126 -0
- package/dist/cli/src/windows-powershell.js +13 -0
- package/dist/router/src/admin.js +16 -10
- package/dist/router/src/compat.js +35 -0
- package/dist/router/src/proxy.js +16 -0
- package/dist/router/src/thinking.js +39 -0
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +4 -1
- package/package.json +7 -3
package/dist/router/src/admin.js
CHANGED
|
@@ -27,6 +27,7 @@ import { codexEnabled, codexHome } from "../../cli/src/codex.js";
|
|
|
27
27
|
import { openBrowser } from "../../cli/src/browser.js";
|
|
28
28
|
import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
|
|
29
29
|
import { certPaths } from "../../cli/src/certs.js";
|
|
30
|
+
import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
|
|
30
31
|
import { syncModelSlots } from "../../cli/src/settings.js";
|
|
31
32
|
import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
32
33
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
@@ -153,18 +154,23 @@ function uiRevision() {
|
|
|
153
154
|
return "0";
|
|
154
155
|
}
|
|
155
156
|
}
|
|
157
|
+
/** Newest CLI the Desktop app has cached, from the same per-platform directories sign-in uses. */
|
|
156
158
|
function cliVersion() {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.filter((d) => /^\d+\.\d+\.\d+$/.test(d))
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
let readable = false;
|
|
160
|
+
const versions = [];
|
|
161
|
+
for (const dir of desktopClaudeCodeDirs()) {
|
|
162
|
+
try {
|
|
163
|
+
versions.push(...fs.readdirSync(dir).filter((d) => /^\d+\.\d+\.\d+$/.test(d)));
|
|
164
|
+
readable = true;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* not this platform's layout, or no Desktop app */
|
|
168
|
+
}
|
|
164
169
|
}
|
|
165
|
-
|
|
170
|
+
if (!readable)
|
|
166
171
|
return "n/a";
|
|
167
|
-
}
|
|
172
|
+
versions.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
173
|
+
return versions.at(-1) ?? "none";
|
|
168
174
|
}
|
|
169
175
|
function tcpReachable(hostname, port, timeoutMs = 2000) {
|
|
170
176
|
return new Promise((resolveP) => {
|
|
@@ -328,7 +334,7 @@ function pickerTrust() {
|
|
|
328
334
|
return pickerTrustMemo.value;
|
|
329
335
|
let value = { caTrusted: false, appProxy: false };
|
|
330
336
|
try {
|
|
331
|
-
value = { caTrusted: caTrusted(), appProxy: currentAppProxy().ours };
|
|
337
|
+
value = { caTrusted: caTrusted(certPaths(homeDir()).caPem), appProxy: currentAppProxy().ours };
|
|
332
338
|
}
|
|
333
339
|
catch {
|
|
334
340
|
// Unknown counts as not ready; the next poll tries again.
|
|
@@ -66,6 +66,30 @@ function stripCacheControl(value) {
|
|
|
66
66
|
}
|
|
67
67
|
return { value: out, count };
|
|
68
68
|
}
|
|
69
|
+
// Claude Code's first system block is Anthropic billing telemetry
|
|
70
|
+
// ("x-anthropic-billing-header: … cch=… cc_prompt_id=… cc_turn_origin=…"). The CLI adds those
|
|
71
|
+
// per-turn fields only when it believes it is talking to api.anthropic.com — which, behind this
|
|
72
|
+
// router's MITM proxy, it always does — and their values change on every turn. Left in, the block
|
|
73
|
+
// sits at the head of the prefix and nothing after it is ever cached by a vendor that keeps a
|
|
74
|
+
// plain stable-prefix cache. Measured 2026-09-25 on Bailian DeepSeek: all 399 requests of two days
|
|
75
|
+
// reported cached=0 over 69.8M input tokens; two identical bodies cached 3,584 of ~3,700 tokens
|
|
76
|
+
// with the block held constant and 0 on all three calls with its fields varied per request. The
|
|
77
|
+
// translated providers drop it for the same reason (`systemText` in providers/chatgpt/translate.ts,
|
|
78
|
+
// measured 2026-09-13), and it means nothing to a provider that is not Anthropic.
|
|
79
|
+
const BILLING_BLOCK = /^x-anthropic-billing-header:/;
|
|
80
|
+
function stripBillingHeader(system) {
|
|
81
|
+
if (typeof system === "string") {
|
|
82
|
+
const value = system.replace(/^x-anthropic-billing-header:[^\n]*\n*/, "");
|
|
83
|
+
return { value, removed: value === system ? 0 : 1 };
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(system))
|
|
86
|
+
return { value: system, removed: 0 };
|
|
87
|
+
const value = system.filter((block) => {
|
|
88
|
+
const source = record(block);
|
|
89
|
+
return !(source && typeof source.text === "string" && BILLING_BLOCK.test(source.text));
|
|
90
|
+
});
|
|
91
|
+
return { value, removed: system.length - value.length };
|
|
92
|
+
}
|
|
69
93
|
/**
|
|
70
94
|
* Remove extensions Claude Code sends only to Anthropic. This deliberately retains content
|
|
71
95
|
* verbatim except cache_control when the provider explicitly rejects prompt caching.
|
|
@@ -160,6 +184,17 @@ export function sanitizeForCompatible(json, caps) {
|
|
|
160
184
|
changes.push("tool_choice");
|
|
161
185
|
}
|
|
162
186
|
}
|
|
187
|
+
if ("system" in out) {
|
|
188
|
+
const next = stripBillingHeader(out.system);
|
|
189
|
+
if (next.removed > 0) {
|
|
190
|
+
// An emptied array would be a system the provider has to reject; the field goes with the block.
|
|
191
|
+
if (Array.isArray(next.value) && next.value.length === 0)
|
|
192
|
+
delete out.system;
|
|
193
|
+
else
|
|
194
|
+
out.system = next.value;
|
|
195
|
+
changes.push(`billing_header×${next.removed}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
163
198
|
if (!caps.cacheControl) {
|
|
164
199
|
let removed = 0;
|
|
165
200
|
for (const key of ["system", "messages"]) {
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -35,6 +35,7 @@ import { injectPickerModels, isBootstrapPath } from "./picker.js";
|
|
|
35
35
|
import { ResponseUsageTap } from "./requestlog.js";
|
|
36
36
|
import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
|
|
37
37
|
import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
|
|
38
|
+
import { dropForeignThinking } from "./thinking.js";
|
|
38
39
|
const MAX_BODY = 64 * 1024 * 1024;
|
|
39
40
|
const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length"]);
|
|
40
41
|
// The caller's own Anthropic credentials. A routed request authenticates as the provider, so these
|
|
@@ -740,6 +741,7 @@ export class Proxy {
|
|
|
740
741
|
finish("401", out.length, "no usable Claude account");
|
|
741
742
|
return;
|
|
742
743
|
}
|
|
744
|
+
dropForeignThinking(json);
|
|
743
745
|
body = Buffer.from(JSON.stringify(json));
|
|
744
746
|
chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
|
|
745
747
|
const using = chosen ?? credentials[0];
|
|
@@ -762,6 +764,17 @@ export class Proxy {
|
|
|
762
764
|
tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
|
|
763
765
|
}
|
|
764
766
|
else {
|
|
767
|
+
// An Anthropic-shaped endpoint keeps no server-side thread either, so a `continue` is refused
|
|
768
|
+
// here as it is for the translated providers. sanitizeForCompatible used to strip the field
|
|
769
|
+
// and forward the rest, which is only the delta: DeepSeek (via Bailian) received two orphan
|
|
770
|
+
// tool_results with no task and answered that none had been given (measured 2026-09-24, CLI
|
|
771
|
+
// 2.1.280). Refused, the CLI resends the turn with the full history.
|
|
772
|
+
if (threadDecision(json) === "refuse") {
|
|
773
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
774
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
775
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
765
778
|
const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
|
|
766
779
|
const modelEffortLevels = provider.models?.find((entry) => entry.id === route.model)?.effortLevels;
|
|
767
780
|
compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
|
|
@@ -820,6 +833,9 @@ export class Proxy {
|
|
|
820
833
|
else if (isApiHost) {
|
|
821
834
|
target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
|
|
822
835
|
tag = `PASS ${typeof model === "string" ? model : "-"}`;
|
|
836
|
+
// Passthrough stays byte-exact unless another model's thinking would get the turn refused.
|
|
837
|
+
if (json && dropForeignThinking(json) > 0)
|
|
838
|
+
body = Buffer.from(JSON.stringify(json));
|
|
823
839
|
}
|
|
824
840
|
else {
|
|
825
841
|
target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Thinking blocks another model left in a conversation that is now going to Anthropic.
|
|
2
|
+
//
|
|
3
|
+
// The translated providers hand the CLI their reasoning as `thinking` blocks with no signature —
|
|
4
|
+
// there is no Anthropic signature to give — and an empty `thinking` when the upstream sent a
|
|
5
|
+
// reasoning item without a summary. The CLI keeps them in the transcript, so a session switched
|
|
6
|
+
// from such a model to a Claude model replays them, and Anthropic refuses the whole request:
|
|
7
|
+
// "messages.2.content.0.thinking: each thinking block must contain thinking" (measured 2026-09-28,
|
|
8
|
+
// gpt-6-astra → claude-opus-5-5, every retry of the turn). A block Anthropic wrote always carries a
|
|
9
|
+
// signature, so an unsigned one is foreign and goes; the translated providers drop thinking from
|
|
10
|
+
// replayed history for the same reason (ARCHITECTURE §4, "Cache-safety decisions").
|
|
11
|
+
function foreign(block) {
|
|
12
|
+
const b = block;
|
|
13
|
+
return !!b && typeof b === "object" && b.type === "thinking" && !(typeof b.signature === "string" && b.signature.length > 0);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Removes unsigned thinking blocks from assistant turns, in place, and returns how many went.
|
|
17
|
+
* A turn left with no content is removed with them: Anthropic refuses an empty assistant turn,
|
|
18
|
+
* and joins the two user turns that then meet.
|
|
19
|
+
*/
|
|
20
|
+
export function dropForeignThinking(json) {
|
|
21
|
+
const messages = json.messages;
|
|
22
|
+
if (!Array.isArray(messages))
|
|
23
|
+
return 0;
|
|
24
|
+
let removed = 0;
|
|
25
|
+
const kept = [];
|
|
26
|
+
for (const message of messages) {
|
|
27
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content) || !message.content.some(foreign)) {
|
|
28
|
+
kept.push(message);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const content = message.content.filter((block) => !foreign(block));
|
|
32
|
+
removed += message.content.length - content.length;
|
|
33
|
+
if (content.length > 0)
|
|
34
|
+
kept.push({ ...message, content });
|
|
35
|
+
}
|
|
36
|
+
if (removed > 0)
|
|
37
|
+
json.messages = kept;
|
|
38
|
+
return removed;
|
|
39
|
+
}
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
|
|
6
6
|
// still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
|
|
7
7
|
// running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
|
|
8
|
-
export const VERSION = "0.
|
|
8
|
+
export const VERSION = "0.5.1";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -493,7 +493,9 @@ chat is out of reach for every approach, ours included.
|
|
|
493
493
|
turn stateless and keeps the session stateless on that model. Accepting the delta
|
|
494
494
|
instead made the model see an orphan `tool_result`, answer "무엇을 도와드릴까요?", and
|
|
495
495
|
kept `cached_tokens` stuck at the tools prefix. `thread`/`diagnostics` are stripped
|
|
496
|
-
from `create` requests before forwarding.
|
|
496
|
+
from `create` requests before forwarding. An `anthropic-compatible` vendor holds no
|
|
497
|
+
thread either and is refused the same way: forwarding the stripped delta sent DeepSeek
|
|
498
|
+
(via Bailian) two orphan `tool_result`s and no task (measured 2026-09-24, CLI 2.1.280, #29).
|
|
497
499
|
- **Per-turn billing telemetry.** The first system block is
|
|
498
500
|
`x-anthropic-billing-header: … cch=<hash> …` and the hash changes every turn. Dropped
|
|
499
501
|
from `instructions`, otherwise nothing after it is ever cached. With it dropped and
|
|
@@ -833,6 +835,7 @@ what we do not have yet — so that adding a provider does not start with readin
|
|
|
833
835
|
| 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. |
|
|
834
836
|
| 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. |
|
|
835
837
|
| 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. |
|
|
838
|
+
| A session switched from gpt-6-astra to claude-opus-5-5 failed every turn with `400 messages.2.content.0.thinking: each thinking block must contain thinking`: the translated adapters hand the CLI reasoning as `thinking` blocks with no signature (and no text when the reasoning item had no summary), the CLI keeps them, and passthrough replayed them to Anthropic (2026-09-28) | Before a request reaches Anthropic (`PASS` and the account-pool provider), assistant `thinking` blocks without a signature are dropped (`thinking.ts`) — Anthropic's own always carry one — and a turn left empty goes with them. Passthrough stays byte-exact when there is nothing to drop. |
|
|
836
839
|
|
|
837
840
|
## 6. Blocked paths (measured, do not retry)
|
|
838
841
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Use GPT and 400+ other models
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Use GPT (ChatGPT Plus/Pro), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"workspaces": [
|
|
7
7
|
"packages/*"
|
|
@@ -58,7 +58,11 @@
|
|
|
58
58
|
"proxy",
|
|
59
59
|
"model-router",
|
|
60
60
|
"ai-coding-assistant",
|
|
61
|
-
"
|
|
61
|
+
"codex-cli",
|
|
62
|
+
"chatgpt-plus",
|
|
63
|
+
"claude-code-proxy",
|
|
64
|
+
"llm-gateway",
|
|
65
|
+
"gpt-6"
|
|
62
66
|
],
|
|
63
67
|
"homepage": "https://github.com/PBJ-2/clauderipple",
|
|
64
68
|
"bugs": {
|