clauderipple 0.8.0 → 0.8.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 +26 -0
- package/README.ko.md +3 -1
- package/README.md +4 -2
- package/dist/app/package.json +1 -1
- package/dist/cli/src/picker.js +19 -11
- package/dist/router/src/providers/chatgpt/index.js +35 -3
- package/dist/router/src/proxy.js +31 -8
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +20 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.1 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Codex credits are spent instead of refused.** Once a ChatGPT plan's window read 100%, the
|
|
8
|
+
router rested the account until the reset, even though OpenAI serves an account with bought
|
|
9
|
+
credits past its window. An account with credits (or an unlimited balance) now stays in
|
|
10
|
+
rotation; a real usage-limit refusal still rests it.
|
|
11
|
+
- **Fewer failed Claude app requests in picker mode.** claude.ai closes idle kept-alive
|
|
12
|
+
connections, and a request written on one as it closed came back as a 502 (about 65 a day
|
|
13
|
+
here, mostly the app's start-up requests). A GET, HEAD or OPTIONS that meets such a socket is
|
|
14
|
+
now sent again; a POST is not.
|
|
15
|
+
- **Picker mode on a new Mac.** The ClaudeRipple CA counted as trusted when it was merely in the
|
|
16
|
+
login keychain, so `picker on` could skip the trust step and leave Claude Desktop unable to
|
|
17
|
+
load claude.ai. Trust is now read from the user trust settings, and `picker off` removes the
|
|
18
|
+
certificate either way.
|
|
19
|
+
- **The route band said "router not answering"** on Claude Code 2.1.288, which sends plain
|
|
20
|
+
`http://` requests through the proxy too and got 405. They are now relayed.
|
|
21
|
+
|
|
22
|
+
### Docs
|
|
23
|
+
|
|
24
|
+
- Any ChatGPT plan that includes Codex works (Plus, Pro, Business; Enterprise/Edu with Codex
|
|
25
|
+
turned on), not just Plus and Pro.
|
|
26
|
+
- `ultra` effort is sent as `max`: the Codex backend refuses `ultra` (measured), and Codex's own
|
|
27
|
+
ultra is max reasoning plus its sub-agents, which in Claude Code are Claude Code's subagents.
|
|
28
|
+
|
|
3
29
|
## 0.8.0 — 2026-10-05
|
|
4
30
|
|
|
5
31
|
### Added
|
package/README.ko.md
CHANGED
|
@@ -101,7 +101,9 @@ Anthropic이 만든 커넥터가 전부 그대로 동작합니다. 1P에 남는
|
|
|
101
101
|
|
|
102
102
|
### OpenAI API 키가 필요한가요?
|
|
103
103
|
|
|
104
|
-
필요 없습니다.
|
|
104
|
+
필요 없습니다. Codex가 포함된 ChatGPT 요금제면 됩니다 — Plus·Pro·Business, 그리고 워크스페이스 관리자가
|
|
105
|
+
Codex를 켜 둔 Enterprise·Edu. 앱에서 로그인하면 되고 API 키는 쓰지 않습니다. 사용량 한도는 요금제 것을 따르며,
|
|
106
|
+
한도를 다 쓰면 구매해 둔 Codex 크레딧으로 이어서 씁니다.
|
|
105
107
|
API 키는 DeepSeek·Kimi·GLM·OpenRouter처럼 다른 프로바이더를 붙일 때만 씁니다.
|
|
106
108
|
|
|
107
109
|
### 어떤 모델을 쓸 수 있나요?
|
package/README.md
CHANGED
|
@@ -114,8 +114,10 @@ Claude. Most people map one or two names to GPT and leave the rest alone.
|
|
|
114
114
|
|
|
115
115
|
### Do I need an OpenAI API key?
|
|
116
116
|
|
|
117
|
-
No.
|
|
118
|
-
|
|
117
|
+
No. Any ChatGPT plan that includes Codex works — Plus, Pro and Business, and Enterprise or
|
|
118
|
+
Edu where the workspace admin has turned Codex on. You sign in from the app and no API key
|
|
119
|
+
is involved. The plan's usage limits apply; once they are used up, Codex credits you have
|
|
120
|
+
bought carry on. API keys are for the other providers (DeepSeek, Kimi, GLM, OpenRouter and
|
|
119
121
|
anything else with an Anthropic- or OpenAI-compatible endpoint).
|
|
120
122
|
|
|
121
123
|
### Which models can I use?
|
package/dist/app/package.json
CHANGED
package/dist/cli/src/picker.js
CHANGED
|
@@ -152,8 +152,12 @@ export function caTrusted(caPem) {
|
|
|
152
152
|
const out = powershell(`@(Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' }).Count`);
|
|
153
153
|
return Number(out.trim()) > 0;
|
|
154
154
|
}
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
// Ask for the trust setting, not the keychain item: the certificate can sit in the login keychain
|
|
156
|
+
// with no trust (new Mac, 2026-10-06), and a presence check then made `picker on` skip the trust
|
|
157
|
+
// step while Claude Desktop rejected every claude.ai certificate (ERR_CERT_AUTHORITY_INVALID).
|
|
158
|
+
// `add-trusted-cert` without -d writes the user trust domain, which this lists one "Cert N: <name>" per line.
|
|
159
|
+
const out = execFileSync("security", ["dump-trust-settings"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
160
|
+
return out.split("\n").some((line) => /^Cert \d+: /.test(line) && line.slice(line.indexOf(": ") + 2).trim() === CA_NAME);
|
|
157
161
|
}
|
|
158
162
|
catch {
|
|
159
163
|
return false;
|
|
@@ -175,9 +179,10 @@ export function trustCa(caPem) {
|
|
|
175
179
|
export function untrustCa(caPem) {
|
|
176
180
|
if (isLinux)
|
|
177
181
|
return nssUntrust();
|
|
178
|
-
|
|
179
|
-
return false;
|
|
182
|
+
const trusted = caTrusted(caPem);
|
|
180
183
|
if (isWindows) {
|
|
184
|
+
if (!trusted)
|
|
185
|
+
return false;
|
|
181
186
|
try {
|
|
182
187
|
powershell(`Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' } | ForEach-Object { Remove-Item -Path $_.PSPath -Force }`, { stdio: "inherit", interactive: true });
|
|
183
188
|
}
|
|
@@ -186,19 +191,22 @@ export function untrustCa(caPem) {
|
|
|
186
191
|
}
|
|
187
192
|
return true;
|
|
188
193
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
// macOS: an untrusted copy can still be in the keychain, so the item is deleted either way.
|
|
195
|
+
if (trusted) {
|
|
196
|
+
try {
|
|
197
|
+
execFileSync("security", ["remove-trusted-cert", caPem], { stdio: "inherit" });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* trust settings may already be gone */
|
|
201
|
+
}
|
|
194
202
|
}
|
|
195
203
|
try {
|
|
196
204
|
execFileSync("security", ["delete-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
|
|
205
|
+
return true;
|
|
197
206
|
}
|
|
198
207
|
catch {
|
|
199
|
-
|
|
208
|
+
return trusted;
|
|
200
209
|
}
|
|
201
|
-
return true;
|
|
202
210
|
}
|
|
203
211
|
function readMeta() {
|
|
204
212
|
try {
|
|
@@ -97,13 +97,36 @@ export function rateLimitsFromHeaders(h) {
|
|
|
97
97
|
if (!primary)
|
|
98
98
|
return null;
|
|
99
99
|
const secondary = window("secondary");
|
|
100
|
+
const flag = (k) => {
|
|
101
|
+
const v = h.get(k)?.toLowerCase();
|
|
102
|
+
return v === "true" ? true : v === "false" ? false : undefined;
|
|
103
|
+
};
|
|
104
|
+
const credits = creditsSnapshot(flag("x-codex-credits-has-credits"), flag("x-codex-credits-unlimited"), h.get("x-codex-credits-balance"), undefined);
|
|
100
105
|
return {
|
|
101
106
|
type: "codex.rate_limits",
|
|
102
107
|
plan_type: h.get("x-codex-plan-type") ?? undefined,
|
|
103
108
|
rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
|
|
109
|
+
...(credits ? { credits } : {}),
|
|
104
110
|
at: Date.now(),
|
|
105
111
|
};
|
|
106
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The account's purchased credits, which the backend draws on once a plan window is used up — the
|
|
115
|
+
* account keeps answering at 100%. Measured 2026-10-06: response headers carry
|
|
116
|
+
* `x-codex-credits-has-credits: False` / `-unlimited: False` / `-balance: 0` (Python-style
|
|
117
|
+
* booleans), and `/wham/usage` carries `credits: {has_credits, unlimited, overage_limit_reached,
|
|
118
|
+
* balance}`. Null when the backend said nothing about credits.
|
|
119
|
+
*/
|
|
120
|
+
function creditsSnapshot(hasCredits, unlimited, balance, overageReached) {
|
|
121
|
+
if (typeof hasCredits !== "boolean" && typeof unlimited !== "boolean")
|
|
122
|
+
return null;
|
|
123
|
+
return {
|
|
124
|
+
has_credits: hasCredits === true,
|
|
125
|
+
unlimited: unlimited === true,
|
|
126
|
+
...(typeof balance === "string" || typeof balance === "number" ? { balance: String(balance) } : {}),
|
|
127
|
+
...(typeof overageReached === "boolean" ? { overage_limit_reached: overageReached } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
107
130
|
/**
|
|
108
131
|
* Map the `/wham/usage` JSON body onto the same shape as `rateLimitsFromHeaders`, so `/api/status`
|
|
109
132
|
* and the GUI read one thing whether the snapshot came from response headers or the active call.
|
|
@@ -131,10 +154,13 @@ export function rateLimitsFromUsage(body) {
|
|
|
131
154
|
if (!primary)
|
|
132
155
|
return null;
|
|
133
156
|
const secondary = win(b?.rate_limit?.secondary_window);
|
|
157
|
+
const c = b?.credits;
|
|
158
|
+
const credits = c ? creditsSnapshot(c.has_credits, c.unlimited, c.balance, c.overage_limit_reached) : null;
|
|
134
159
|
return {
|
|
135
160
|
type: "codex.rate_limits",
|
|
136
161
|
plan_type: typeof b?.plan_type === "string" ? b.plan_type : undefined,
|
|
137
162
|
rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
|
|
163
|
+
...(credits ? { credits } : {}),
|
|
138
164
|
at: Date.now(),
|
|
139
165
|
};
|
|
140
166
|
}
|
|
@@ -182,9 +208,14 @@ function windowResetMs(w, now) {
|
|
|
182
208
|
/**
|
|
183
209
|
* How long an account is out, from a rate-limit snapshot: the latest reset among the windows it
|
|
184
210
|
* has used up, since it is usable only once every full window has reset. Undefined when no window
|
|
185
|
-
* is full — the account is not out, whatever else the snapshot says
|
|
211
|
+
* is full — the account is not out, whatever else the snapshot says — and undefined when the
|
|
212
|
+
* account has credits to spend past the window: the backend keeps serving it, and when the credits
|
|
213
|
+
* run out it says so with a 429, which rests the account the ordinary way.
|
|
186
214
|
*/
|
|
187
|
-
export function exhaustedForMs(snapshot, now = Date.now()) {
|
|
215
|
+
export function exhaustedForMs(snapshot, now = Date.now(), creditsCount = true) {
|
|
216
|
+
const credits = snapshot?.credits;
|
|
217
|
+
if (creditsCount && credits && (credits.has_credits === true || credits.unlimited === true) && credits.overage_limit_reached !== true)
|
|
218
|
+
return undefined;
|
|
188
219
|
const limits = (snapshot?.rate_limits ?? null);
|
|
189
220
|
let out;
|
|
190
221
|
for (const w of [limits?.primary, limits?.secondary]) {
|
|
@@ -762,7 +793,8 @@ export class ChatGptAdapter {
|
|
|
762
793
|
const headerRecord = Object.fromEntries(upstream.headers.entries());
|
|
763
794
|
const snapshot = rateLimitsFromHeaders(upstream.headers);
|
|
764
795
|
const retryHeader = headerRecord["retry-after"] ? retryAfterMs({ "retry-after": headerRecord["retry-after"] }) : undefined;
|
|
765
|
-
|
|
796
|
+
// The account was refused, so whatever credits the headers report did not carry it past the window.
|
|
797
|
+
const waitMs = retryHeader ?? exhaustedForMs(snapshot, Date.now(), false) ?? retryAfterMs(headerRecord);
|
|
766
798
|
// A refusal here is a rest, never a pool quarantine: "sign in again" is the store's to say
|
|
767
799
|
// (needsReauth, above), and a quarantine would outlive the new token a sign-in brings.
|
|
768
800
|
const verdict = this.pool.penalise(this.name, credential.id, credentialRefused ? 403 : status, waitMs, text);
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -47,11 +47,15 @@ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "tra
|
|
|
47
47
|
const CLIENT_AUTH = new Set(["authorization", "x-api-key"]);
|
|
48
48
|
/** How much of an upstream error body is kept for the log. */
|
|
49
49
|
const ERROR_HEAD_MAX = 4096;
|
|
50
|
+
/** Methods safe to send twice (RFC 9110 §9.2.2), and so the only ones resent after a stale socket. */
|
|
51
|
+
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
50
52
|
/**
|
|
51
53
|
* Claude Code's Remote Control registration uses HTTPS absolute-form proxy requests instead of
|
|
52
54
|
* CONNECT (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`). Convert that legal
|
|
53
55
|
* forward-proxy form to the origin form expected inside a TLS connection. Credentials and the
|
|
54
56
|
* request body remain byte-for-byte client data; proxy-only headers never reach the destination.
|
|
57
|
+
* Plain `http:` takes the same form and is relayed over TCP: Claude Code 2.1.288 sends its http
|
|
58
|
+
* requests through HTTPS_PROXY too, loopback included (a mod reading the admin API got our 405).
|
|
55
59
|
*/
|
|
56
60
|
export function absoluteProxyRequest(header) {
|
|
57
61
|
const lines = header.toString("latin1").split("\r\n");
|
|
@@ -66,9 +70,10 @@ export function absoluteProxyRequest(header) {
|
|
|
66
70
|
catch {
|
|
67
71
|
return null;
|
|
68
72
|
}
|
|
69
|
-
|
|
73
|
+
const secure = url.protocol === "https:";
|
|
74
|
+
if ((!secure && url.protocol !== "http:") || url.username || url.password || url.hash || !url.hostname)
|
|
70
75
|
return null;
|
|
71
|
-
const port = url.port ? Number(url.port) : 443;
|
|
76
|
+
const port = url.port ? Number(url.port) : secure ? 443 : 80;
|
|
72
77
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
73
78
|
return null;
|
|
74
79
|
// URL.hostname keeps brackets around IPv6 literals; net/tls expect the bare address while the
|
|
@@ -79,10 +84,10 @@ export function absoluteProxyRequest(header) {
|
|
|
79
84
|
!/^connection\s*:/i.test(line));
|
|
80
85
|
const authority = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
81
86
|
const path = `${url.pathname || "/"}${url.search}`;
|
|
82
|
-
// One
|
|
87
|
+
// One upstream connection serves one absolute-form request. Reusing it would send the next proxy-form
|
|
83
88
|
// request target directly to the origin, so explicitly ask both peers to close after the response.
|
|
84
89
|
const rewritten = [`${match[1]} ${path} ${match[3]}`, `Host: ${authority}`, "Connection: close", ...headers].join("\r\n") + "\r\n\r\n";
|
|
85
|
-
return { host, port, path, head: Buffer.from(rewritten, "latin1") };
|
|
90
|
+
return { host, port, path, head: Buffer.from(rewritten, "latin1"), tls: secure };
|
|
86
91
|
}
|
|
87
92
|
/**
|
|
88
93
|
* The same header list with one credential swapped for another. Names carried by either credential
|
|
@@ -425,7 +430,7 @@ export class Proxy {
|
|
|
425
430
|
if (method !== "CONNECT" || !target) {
|
|
426
431
|
const absolute = absoluteProxyRequest(head.subarray(0, end));
|
|
427
432
|
if (absolute) {
|
|
428
|
-
log.info(`ABSOLUTE ${method ?? "?"} https://${absolute.host}:${absolute.port}${absolute.path}`);
|
|
433
|
+
log.info(`ABSOLUTE ${method ?? "?"} ${absolute.tls ? "https" : "http"}://${absolute.host}:${absolute.port}${absolute.path}`);
|
|
429
434
|
this.forwardAbsolute(sock, absolute, rest);
|
|
430
435
|
return;
|
|
431
436
|
}
|
|
@@ -450,7 +455,9 @@ export class Proxy {
|
|
|
450
455
|
/** Forward an HTTPS absolute-form request without terminating it through the routing layer. */
|
|
451
456
|
forwardAbsolute(sock, request, rest) {
|
|
452
457
|
const servername = net.isIP(request.host) ? undefined : request.host;
|
|
453
|
-
const up =
|
|
458
|
+
const up = request.tls
|
|
459
|
+
? (this.deps.tlsConnect ?? tls.connect)({ host: request.host, port: request.port, ...(servername ? { servername } : {}), ALPNProtocols: ["http/1.1"] })
|
|
460
|
+
: net.connect({ host: request.host, port: request.port });
|
|
454
461
|
const kill = () => {
|
|
455
462
|
sock.destroy();
|
|
456
463
|
up.destroy();
|
|
@@ -462,7 +469,7 @@ export class Proxy {
|
|
|
462
469
|
up.destroy();
|
|
463
470
|
});
|
|
464
471
|
sock.on("error", kill);
|
|
465
|
-
up.once("secureConnect", () => {
|
|
472
|
+
up.once(request.tls ? "secureConnect" : "connect", () => {
|
|
466
473
|
up.write(request.head);
|
|
467
474
|
if (rest.length)
|
|
468
475
|
up.write(rest);
|
|
@@ -936,7 +943,7 @@ export class Proxy {
|
|
|
936
943
|
}
|
|
937
944
|
}
|
|
938
945
|
else if (isApiHost) {
|
|
939
|
-
target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
|
|
946
|
+
target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
|
|
940
947
|
tag = `PASS ${typeof model === "string" ? model : "-"}`;
|
|
941
948
|
// Passthrough stays byte-exact unless another model's thinking would get the turn refused.
|
|
942
949
|
if (json && dropForeignThinking(json) > 0)
|
|
@@ -1005,7 +1012,11 @@ export class Proxy {
|
|
|
1005
1012
|
let upReq;
|
|
1006
1013
|
/** Credentials already spent on this turn, so a retry cannot pick one of them again. */
|
|
1007
1014
|
const tried = new Set(penalised ? [penalised.id] : []);
|
|
1015
|
+
/** The headers of the attempt in flight, so a stale-socket resend repeats exactly that attempt. */
|
|
1016
|
+
let lastAttemptHeaders = headers;
|
|
1017
|
+
let staleRetries = 0;
|
|
1008
1018
|
const send = (attemptHeaders) => {
|
|
1019
|
+
lastAttemptHeaders = attemptHeaders;
|
|
1009
1020
|
upReq = lib.request({
|
|
1010
1021
|
protocol: target.protocol,
|
|
1011
1022
|
host: target.host,
|
|
@@ -1073,6 +1084,18 @@ export class Proxy {
|
|
|
1073
1084
|
}
|
|
1074
1085
|
});
|
|
1075
1086
|
const onUpstreamError = (e) => {
|
|
1087
|
+
// A kept-alive socket the upstream had already closed: the reset arrives before any response,
|
|
1088
|
+
// so the request never reached it. claude.ai did this 259 times in four days of picker mode
|
|
1089
|
+
// (2026-10-03..06), each one a 502 on an app request such as `current_user_access`. Send an
|
|
1090
|
+
// idempotent request again on a fresh attempt instead of failing it; a POST is never resent,
|
|
1091
|
+
// since a reset cannot prove the upstream did not act on it.
|
|
1092
|
+
const code = e.code;
|
|
1093
|
+
if (upReq.reusedSocket && (code === "ECONNRESET" || code === "EPIPE") && IDEMPOTENT.has(method) && !clientAborted && !res.headersSent && staleRetries < 2) {
|
|
1094
|
+
staleRetries++;
|
|
1095
|
+
log.info(`STALE ${reqHost}: kept-alive socket was closed (${code}); resending ${method} ${path.slice(0, 60)}`);
|
|
1096
|
+
send(lastAttemptHeaders);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1076
1099
|
if (target.host === cfg.upstream)
|
|
1077
1100
|
this.deps.health.failure(e);
|
|
1078
1101
|
// Nothing reached the provider, so this says nothing about the credential — but it does say
|
|
@@ -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.
|
|
8
|
+
export const VERSION = "0.8.1";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -302,8 +302,22 @@ request went, and a `Pane` holds the request log.
|
|
|
302
302
|
`/zen/v1/responses`, not the Codex backend): gpt-6-sol, gpt-6-luna,
|
|
303
303
|
gpt-5.6-sol and gpt-5.6-luna all accept `none, low, medium, high, xhigh,
|
|
304
304
|
max` and answer `400 param: reasoning.effort` to both `minimal` and
|
|
305
|
-
`ultra`. So `ultra` is not a level that API takes on any of them
|
|
306
|
-
the Codex backend
|
|
305
|
+
`ultra`. So `ultra` is not a level that API takes on any of them. **Settled
|
|
306
|
+
2026-10-06 on the Codex backend too:** gpt-6.1-sol with `reasoning.effort:
|
|
307
|
+
"ultra"` answered `400 Invalid value: 'ultra'. Supported values are: 'none',
|
|
308
|
+
'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'`, while `max` answered
|
|
309
|
+
200. The catalogue's own description of `ultra` is "Maximum reasoning with
|
|
310
|
+
automatic task delegation": it is `max` on the wire plus the Codex CLI
|
|
311
|
+
spawning its own sub-agents, which in Claude Code are Claude Code's
|
|
312
|
+
subagents. Keep the clamp. (`reasoning.mode` takes `standard`/`pro`, and
|
|
313
|
+
gpt-6.1-sol refuses `pro`: "not supported with this model".)
|
|
314
|
+
- **Credits (2026-10-06).** Every Codex response carries
|
|
315
|
+
`x-codex-credits-has-credits` / `-unlimited` (`True`/`False`) and
|
|
316
|
+
`-balance`; `/wham/usage` has `credits: {has_credits, unlimited,
|
|
317
|
+
overage_limit_reached, balance}`. OpenAI's help centre: included usage is
|
|
318
|
+
spent first, then purchased credits. So a window at 100% with credits left
|
|
319
|
+
is not an exhausted account — `exhaustedForMs` no longer rests it, and a
|
|
320
|
+
real 429 still does.
|
|
307
321
|
- **Model catalogue (2026-09-23).** `GET {base}/codex/models?client_version=<v>`
|
|
308
322
|
with the same credentials answers `{ models: [{ slug, display_name,
|
|
309
323
|
visibility: "list"|"hide", context_window, supported_reasoning_levels:
|
|
@@ -1097,6 +1111,7 @@ subscription exists in this environment. Files are under `src/`:
|
|
|
1097
1111
|
| Windows: the scheduled task inherited the console that started it, so closing the installer window killed the router with `0xC000013A` (Ctrl+C), 2026-09-14 | The task runs a PowerShell launcher that uses `Start-Process -PassThru -Wait`: detached from any console, still tracked so `RestartCount` keeps acting as KeepAlive and the exit code (including the health self-exit 75) propagates. |
|
|
1098
1112
|
| Windows: `install` registered a logon-triggered task and stopped there, so the router did not exist until the next sign-in — launchd starts at bootstrap (`RunAtLoad`) and the difference was invisible | `install` starts the supervisor itself on both platforms and prints the result. |
|
|
1099
1113
|
| Windows: trusting the CA failed with "this operation cannot use the UI" — every PowerShell call carried `-NonInteractive`, and the confirmation dialog never appeared, so picker mode stopped with no certificate and no explanation | Certificate trust and removal run WITHOUT `-NonInteractive` (and without `windowsHide`). They are UI operations by design: the OS must be able to show the user what it is being asked to trust. |
|
|
1114
|
+
| macOS, new Mac (2026-10-06): picker mode on, router up, and Claude Desktop would not open — every claude.ai load failed `ERR_CERT_AUTHORITY_INVALID`, router logged `tls alert certificate unknown`; `dump-trust-settings` had no entry for the CA. "Trusted" was decided by `find-certificate` (the item is in the login keychain), which says nothing about trust, so `picker on` could skip the trust step and `status` could read an untrusted CA as trusted | On macOS the CA counts as trusted only when the user trust domain (`security dump-trust-settings`) lists it. `picker off` deletes the keychain item even when it carries no trust. |
|
|
1100
1115
|
| Windows: sign-in failed with `missing_required_parameter` because the OAuth URL was opened via `cmd /c start`, and cmd reads `&` as a command separator — everything after the first parameter was cut off and run as commands (2026-09-14) | Browsers are opened with `Start-Process <url>` as a single quoted argument. Any URL we hand to a shell must survive its metacharacters. |
|
|
1101
1116
|
| The provider form's "also show in the Claude app picker" box only records `cli.extraModels`; picker mode itself stayed off, nothing was injected, and the user believed the picker was on (Windows x64, 2026-09-14: `config.json` had no `picker` key, no CA, no Config Library) | When that box is ticked while picker mode is off, the form says so under the box and offers to turn picker mode on right after saving. Any control that *looks* like it enables picker mode must either enable it or say what is still missing. |
|
|
1102
1117
|
| A DeepSeek mapping answered `401` on every real request while the provider form's own "Test connection" stayed green — the test sends the provider's headers alone, live traffic also carried the caller's `authorization` (2026-09-15, Windows) | A routed request carries provider authentication only (§4). The connection test and live traffic must present the same auth set, or the test certifies a path nobody uses. |
|
|
@@ -1113,6 +1128,7 @@ subscription exists in this environment. Files are under `src/`:
|
|
|
1113
1128
|
| 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. |
|
|
1114
1129
|
| 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. |
|
|
1115
1130
|
| 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. |
|
|
1131
|
+
| The status mod's band said "router not answering" on every tick while the admin API answered curl (2026-10-06, CLI 2.1.288): the host's `$.http.fetch("http://127.0.0.1:8792/…")` went through `HTTPS_PROXY` as plain-HTTP absolute-form (`GET http://127.0.0.1:8792/… HTTP/1.1`), loopback included, and the proxy socket answered 405 to anything but CONNECT or `https:` absolute-form. Measured with a probe mod under `claude -p --plugin-dir`: 405 with an empty body | `http:` absolute-form is relayed like `https:` (same header rewrite, `Connection: close`) over plain TCP. A live loopback origin test covers it. |
|
|
1116
1132
|
| 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. |
|
|
1117
1133
|
| 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. |
|
|
1118
1134
|
| 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. |
|
|
@@ -1121,6 +1137,8 @@ subscription exists in this environment. Files are under `src/`:
|
|
|
1121
1137
|
| Re-running the installer — the documented update — replaced the files and left the router running the old code: `install` treats a running router as done, and on Windows and Linux an unchanged supervisor definition is not re-registered, so nothing restarted it until the next login (found 2026-09-28 while building `update`; the tray's own restart covered only the packaged app) | `install` asks a router that is already running for its version and restarts one that differs. Measured in the Windows 11 VM: 0.5.1 running, installer run with 0.6.0 → "restarting it on 0.6.0", status 0.6.0. |
|
|
1122
1138
|
| The tray's Electron (~270MB) was fetched into the installed package's own `node_modules`, and `npm install -g` of any other version deleted it with the old files (measured 2026-09-28): every update silently took the tray away, and on Windows a running tray held `electron.exe` inside the folder npm had to replace | Electron lives in `<home>/tray-runtime`, outside anything an update replaces. Both install scripts move one found in the old place there before installing (Windows first stops a tray running from it, and starts it again after setup) — checked on macOS and in the VM with a locked `electron.exe`. |
|
|
1123
1139
|
| Adding a provider in the settings window restored three OpenCode effort ladders realigned by hand five hours earlier, so the app's effort menus for DeepSeek and GLM never narrowed (2026-10-02 23:13, found 10-05). `PUT /api/config` takes the whole config, and the screen built it from the copy it loaded at open and never re-read; the router itself also writes `config.json` (a measurement's results), so any of those was undone by the next unrelated save | Every GUI save fetches the router's config first and applies its own change to that. A provider form, which holds per-model fields (wire, ladder, window) from when it opened and has no control for them, takes a saved model's values from the router's copy. The provider and client screens re-read the config whenever they load. |
|
|
1140
|
+
| In picker mode, claude.ai requests failed as `502 … ECONNRESET` in under a second — 259 times in four days (2026-10-03..06), mostly `current_user_access`, `desktop/features` and `event_logging`. The kept-alive upstream socket had been closed by claude.ai as the next request was written on it, and Node does not resend | A GET/HEAD/OPTIONS whose reused socket resets before any response is sent again (twice at most), logged `STALE`. A POST is never resent: a reset does not prove the upstream did not act on it. Not yet shown to be the cause of the Windows "chat history loads forever" report (DC, 2026-10-06). |
|
|
1141
|
+
| ChatGPT accounts that had bought Codex credits were rested by the router the moment a window read 100%, though OpenAI serves them from credits past the window | `exhaustedForMs` ignores a full window while `credits.has_credits` or `unlimited` holds (and `overage_limit_reached` does not); an actual 429 still rests the account until the window resets. |
|
|
1124
1142
|
|
|
1125
1143
|
## 6. Blocked paths (measured, do not retry)
|
|
1126
1144
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.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": [
|