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.
@@ -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
- const dir = path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code");
158
- try {
159
- const versions = fs
160
- .readdirSync(dir)
161
- .filter((d) => /^\d+\.\d+\.\d+$/.test(d))
162
- .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
163
- return versions.length ? versions[versions.length - 1] : "none";
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
- catch {
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"]) {
@@ -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.4.1";
8
+ export const VERSION = "0.5.1";
@@ -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.1",
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.",
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
- "mcp"
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": {