clauderipple 0.5.0 → 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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 — 2026-09-28
4
+
5
+ ### Fixed
6
+
7
+ - **Prompt caching on anthropic-compatible providers** (#36, #37, by @grapefruit0205).
8
+ Claude Code's billing header opens the system prompt and changes every turn, so a
9
+ vendor with a plain prefix cache cached nothing after it: DeepSeek via Alibaba Bailian
10
+ reported 0.3% over two days. The header is now dropped for these providers, as it
11
+ already was for the translated ones; measured 99% on the next turns.
12
+ - **Switching a session from a translated model to Claude.** The translated providers
13
+ return reasoning as unsigned, sometimes empty `thinking` blocks, and Anthropic refused
14
+ every turn after the switch with "each thinking block must contain thinking". Unsigned
15
+ thinking is now dropped before a request reaches Anthropic.
16
+ - **Windows install when the user name equals the computer name** (#38, reported by
17
+ @haemilchu). The scheduled task is registered as `DOMAIN\user`; the bare name did not
18
+ resolve and registration failed with 0x80070057 before `settings.json` was written.
19
+ - **Windows reinstall no longer re-registers a correct task.** The check read two
20
+ battery settings under names the task object does not have, so it never matched and
21
+ every install registered again — what fails on machines that protect the task.
22
+ Checked in a Windows 11 VM.
23
+ - **`clauderipple` in a new PowerShell window** (#38). The installer leaves no `.ps1`
24
+ shim, so PowerShell runs the `.cmd` under the default execution policy.
25
+
3
26
  ## 0.5.0 — 2026-09-24
4
27
 
5
28
  Linux, and four fixes from the first outside contributions.
@@ -39,6 +39,25 @@ function run(script) {
39
39
  return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() };
40
40
  }
41
41
  }
42
+ /**
43
+ * The account the task runs as, qualified with its domain. A bare name is looked up as a machine
44
+ * name too, so on a local account named after its computer (user X on host X) it resolves to
45
+ * nothing and Register-ScheduledTask fails with 0x80070057 (#38). `X\X` names the user.
46
+ */
47
+ export function taskUser(username = os.userInfo().username, domain = process.env.USERDOMAIN) {
48
+ return domain ? `${domain}\\${username}` : username;
49
+ }
50
+ /**
51
+ * Windows stores the qualified name as given on the trigger but the bare one on the principal
52
+ * (reported in #38), and a task registered before that fix carries the bare name on both, so
53
+ * either spelling of the same account is a match.
54
+ */
55
+ function sameAccount(stored, expected) {
56
+ if (stored === undefined)
57
+ return false;
58
+ const bare = expected.slice(expected.lastIndexOf("\\") + 1);
59
+ return [expected, bare].some((name) => name.toLowerCase() === stored.toLowerCase());
60
+ }
42
61
  export function taskDefinitionMatches(value, expected) {
43
62
  if (!value || typeof value !== "object")
44
63
  return false;
@@ -46,12 +65,12 @@ export function taskDefinitionMatches(value, expected) {
46
65
  return (task.actionCount === 1 &&
47
66
  task.execute?.toLowerCase() === "powershell.exe" &&
48
67
  task.arguments === expected.arguments &&
49
- task.userId?.toLowerCase() === expected.userId.toLowerCase() &&
68
+ sameAccount(task.userId, expected.userId) &&
50
69
  task.runLevel === 0 &&
51
70
  task.logonType === 3 &&
52
71
  task.triggerCount === 1 &&
53
72
  task.triggerType === "MSFT_TaskLogonTrigger" &&
54
- task.triggerUserId?.toLowerCase() === expected.userId.toLowerCase() &&
73
+ sameAccount(task.triggerUserId, expected.userId) &&
55
74
  task.restartCount === 99 &&
56
75
  task.restartInterval === "PT1M" &&
57
76
  task.executionTimeLimit === "PT0S" &&
@@ -98,30 +117,38 @@ function writeLauncher(opts) {
98
117
  fs.writeFileSync(launcher, lines.join("\r\n") + "\r\n");
99
118
  return launcher;
100
119
  }
120
+ /**
121
+ * The registered task in the shape taskDefinitionMatches reads, or null. The settings object has
122
+ * no AllowStartIfOnBatteries/DontStopIfGoingOnBatteries — the switches of the same names set
123
+ * DisallowStartIfOnBatteries/StopIfGoingOnBatteries to false — so reading the switch names gave
124
+ * null, and until #38 no existing task ever matched: every install re-registered.
125
+ */
126
+ export function existingTaskDefinition() {
127
+ const existing = run([
128
+ `$t = Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`,
129
+ `if ($null -eq $t) { 'null'; exit 0 }`,
130
+ `$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
131
+ `[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.DisallowStartIfOnBatteries -eq $false; dontStopIfGoingOnBatteries = $s.StopIfGoingOnBatteries -eq $false } | ConvertTo-Json -Compress`,
132
+ ].join("; "));
133
+ if (!existing.ok)
134
+ return null;
135
+ try {
136
+ return JSON.parse(existing.out);
137
+ }
138
+ catch {
139
+ // A malformed inspection result is not trusted; the caller falls through to registration.
140
+ return null;
141
+ }
142
+ }
101
143
  export function installAgent(opts) {
102
144
  const launcher = writeLauncher({ program: opts.program, args: opts.args ?? [], home: opts.home, ...(opts.env ? { env: opts.env } : {}) });
103
- const user = `${os.userInfo().username}`;
145
+ const user = taskUser();
104
146
  const actionArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
105
147
  // Updating the generated launcher does not require re-registering an otherwise correct task.
106
148
  // Some Windows installations protect an existing per-user task's security descriptor so that
107
149
  // even the same unelevated user gets E_ACCESSDENIED from Register-ScheduledTask -Force. Treat the
108
150
  // verified existing definition as success; never silently accept a same-named foreign task.
109
- const existing = run([
110
- `$t = Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`,
111
- `if ($null -eq $t) { 'null'; exit 0 }`,
112
- `$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
113
- `[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.AllowStartIfOnBatteries; dontStopIfGoingOnBatteries = $s.DontStopIfGoingOnBatteries } | ConvertTo-Json -Compress`,
114
- ].join("; "));
115
- let definition = null;
116
- if (existing.ok) {
117
- try {
118
- definition = JSON.parse(existing.out);
119
- }
120
- catch {
121
- // A malformed inspection result is not trusted; fall through to registration.
122
- }
123
- }
124
- if (taskDefinitionMatches(definition, { arguments: actionArgs, userId: user }))
151
+ if (taskDefinitionMatches(existingTaskDefinition(), { arguments: actionArgs, userId: user }))
125
152
  return launcher;
126
153
  const script = [
127
154
  `$ErrorActionPreference = 'Stop'`,
@@ -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"]) {
@@ -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];
@@ -831,6 +833,9 @@ export class Proxy {
831
833
  else if (isApiHost) {
832
834
  target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
833
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));
834
839
  }
835
840
  else {
836
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.5.0";
8
+ export const VERSION = "0.5.1";
@@ -835,6 +835,7 @@ what we do not have yet — so that adding a provider does not start with readin
835
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. |
836
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. |
837
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. |
838
839
 
839
840
  ## 6. Blocked paths (measured, do not retry)
840
841
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
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": [