clauderipple 0.8.0 → 0.8.2
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 +40 -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/admin.js +58 -9
- package/dist/router/src/capabilities.js +65 -7
- package/dist/router/src/index.js +7 -3
- package/dist/router/src/presets.js +6 -3
- 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 +21 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Effort works on API-key providers.** A provider with no effort ladder of its own — DeepSeek,
|
|
8
|
+
Kimi, GLM, MiniMax, Qwen, Groq, Mistral, or any key and URL — stripped the effort you picked and
|
|
9
|
+
the Claude app showed no effort menu for its models. ClaudeRipple now asks each such model: it
|
|
10
|
+
sends a level no vendor defines, and if the endpoint refuses it, the levels it does accept become
|
|
11
|
+
that model's effort menu. An endpoint that accepts anything is left without effort, since it is
|
|
12
|
+
not reading the field. Providers you already set up are measured a minute after the router
|
|
13
|
+
starts; nothing needs re-saving.
|
|
14
|
+
- **DeepSeek's API offers low / high / max**, the three levels its thinking mode maps every
|
|
15
|
+
request onto.
|
|
16
|
+
|
|
17
|
+
## 0.8.1 — 2026-10-06
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **Codex credits are spent instead of refused.** Once a ChatGPT plan's window read 100%, the
|
|
22
|
+
router rested the account until the reset, even though OpenAI serves an account with bought
|
|
23
|
+
credits past its window. An account with credits (or an unlimited balance) now stays in
|
|
24
|
+
rotation; a real usage-limit refusal still rests it.
|
|
25
|
+
- **Fewer failed Claude app requests in picker mode.** claude.ai closes idle kept-alive
|
|
26
|
+
connections, and a request written on one as it closed came back as a 502 (about 65 a day
|
|
27
|
+
here, mostly the app's start-up requests). A GET, HEAD or OPTIONS that meets such a socket is
|
|
28
|
+
now sent again; a POST is not.
|
|
29
|
+
- **Picker mode on a new Mac.** The ClaudeRipple CA counted as trusted when it was merely in the
|
|
30
|
+
login keychain, so `picker on` could skip the trust step and leave Claude Desktop unable to
|
|
31
|
+
load claude.ai. Trust is now read from the user trust settings, and `picker off` removes the
|
|
32
|
+
certificate either way.
|
|
33
|
+
- **The route band said "router not answering"** on Claude Code 2.1.288, which sends plain
|
|
34
|
+
`http://` requests through the proxy too and got 405. They are now relayed.
|
|
35
|
+
|
|
36
|
+
### Docs
|
|
37
|
+
|
|
38
|
+
- Any ChatGPT plan that includes Codex works (Plus, Pro, Business; Enterprise/Edu with Codex
|
|
39
|
+
turned on), not just Plus and Pro.
|
|
40
|
+
- `ultra` effort is sent as `max`: the Codex backend refuses `ultra` (measured), and Codex's own
|
|
41
|
+
ultra is max reasoning plus its sub-agents, which in Claude Code are Claude Code's subagents.
|
|
42
|
+
|
|
3
43
|
## 0.8.0 — 2026-10-05
|
|
4
44
|
|
|
5
45
|
### 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 {
|
package/dist/router/src/admin.js
CHANGED
|
@@ -981,6 +981,26 @@ function providerLadder(p) {
|
|
|
981
981
|
return [...(p.caps?.effortLevels ?? [])];
|
|
982
982
|
return [];
|
|
983
983
|
}
|
|
984
|
+
/**
|
|
985
|
+
* Whether nothing says what effort this provider's models take, so a request strips it: the case
|
|
986
|
+
* worth measuring a model for. Read the way the request path reads it. An anthropic-compatible
|
|
987
|
+
* provider falls back to its preset live (`compatCaps` in proxy.ts) and strips on an empty ladder; the
|
|
988
|
+
* OpenAI adapter reads only its own `caps`, and `reasoning: "effort"` with no levels passes effort
|
|
989
|
+
* through as asked. An anthropic-compatible ladder the config states is a decision, even an empty
|
|
990
|
+
* one. `reasoning: "none"` is not: the provider form writes it for every OpenAI-compatible provider
|
|
991
|
+
* whose preset has no ladder (Groq, Mistral, a key and a URL), with no control to say otherwise.
|
|
992
|
+
*/
|
|
993
|
+
function effortUndeclared(p) {
|
|
994
|
+
if (p.type === "anthropic-compatible") {
|
|
995
|
+
if (p.caps?.effortLevels !== undefined)
|
|
996
|
+
return false;
|
|
997
|
+
const preset = p.preset ? PRESETS.find((entry) => entry.id === p.preset) : undefined;
|
|
998
|
+
return (preset?.effortLevels ?? []).length === 0;
|
|
999
|
+
}
|
|
1000
|
+
if (p.type === "openai-compatible")
|
|
1001
|
+
return p.caps?.reasoning !== "effort";
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
984
1004
|
/**
|
|
985
1005
|
* Fold measurements into the config on disk.
|
|
986
1006
|
*
|
|
@@ -1025,6 +1045,37 @@ function applyMeasurements(configFile, results) {
|
|
|
1025
1045
|
writeConfigFile(configFile, current);
|
|
1026
1046
|
return { applied };
|
|
1027
1047
|
}
|
|
1048
|
+
/** Register a measurement job and run it detached; returns the id a caller polls. */
|
|
1049
|
+
function startMeasureJob(providerName, models, deps) {
|
|
1050
|
+
sweepMeasureJobs();
|
|
1051
|
+
const jobId = crypto.randomBytes(12).toString("hex");
|
|
1052
|
+
MEASURE_JOBS.set(jobId, { state: "running", done: 0, total: models.length, results: [] });
|
|
1053
|
+
deps.log.info(`admin: measuring ${providerName} (${models.length} model(s)) -> ${jobId.slice(0, 8)}`);
|
|
1054
|
+
void runMeasureJob(jobId, providerName, models, deps);
|
|
1055
|
+
return jobId;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Measure the models whose effort is still stripped and that nobody has settled, without waiting
|
|
1059
|
+
* for a save in the GUI. A model was measured only when its provider was saved, so a provider set up
|
|
1060
|
+
* before effort discovery — every DeepSeek, Kimi or GLM key on 0.8.1 and earlier — would otherwise
|
|
1061
|
+
* stay without effort until its owner happened to save it again. A model with a ladder (measured,
|
|
1062
|
+
* or typed) is the config's and left alone; so is one whose provider declares a ladder.
|
|
1063
|
+
*/
|
|
1064
|
+
export function measureUnsettledModels(deps) {
|
|
1065
|
+
let started = 0;
|
|
1066
|
+
for (const [name, provider] of Object.entries(deps.config().providers)) {
|
|
1067
|
+
if (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")
|
|
1068
|
+
continue;
|
|
1069
|
+
if (!effortUndeclared(provider))
|
|
1070
|
+
continue;
|
|
1071
|
+
const models = (provider.models ?? []).filter((model) => model.effortLevels === undefined).map((model) => model.id);
|
|
1072
|
+
if (models.length === 0)
|
|
1073
|
+
continue;
|
|
1074
|
+
startMeasureJob(name, models, deps);
|
|
1075
|
+
started++;
|
|
1076
|
+
}
|
|
1077
|
+
return started;
|
|
1078
|
+
}
|
|
1028
1079
|
/** Run one provider measurement job to completion. Detached: the request that started it has already answered. */
|
|
1029
1080
|
async function runMeasureJob(jobId, providerName, models, deps) {
|
|
1030
1081
|
const job = MEASURE_JOBS.get(jobId);
|
|
@@ -1036,6 +1087,9 @@ async function runMeasureJob(jobId, providerName, models, deps) {
|
|
|
1036
1087
|
return;
|
|
1037
1088
|
}
|
|
1038
1089
|
const ladder = providerLadder(provider);
|
|
1090
|
+
// No ladder from the provider, its preset or the config: find out per model whether it takes
|
|
1091
|
+
// effort at all (`discoverLadder`), rather than leave it stripped.
|
|
1092
|
+
const discover = effortUndeclared(provider);
|
|
1039
1093
|
const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
|
|
1040
1094
|
const headers = "headers" in provider ? provider.headers : undefined;
|
|
1041
1095
|
const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
|
|
@@ -1047,9 +1101,9 @@ async function runMeasureJob(jobId, providerName, models, deps) {
|
|
|
1047
1101
|
const candidates = measureCandidates(provider, id, listed?.wire);
|
|
1048
1102
|
// A ladder the catalogue states is taken as stated; the wire is still asked, once, because that
|
|
1049
1103
|
// one request is also what finds a model the plan refuses (403 FreeTierError, DataPolicyError).
|
|
1050
|
-
const stated = ladder.length > 0 ? listed?.effortLevels : undefined;
|
|
1104
|
+
const stated = ladder.length > 0 || discover ? listed?.effortLevels : undefined;
|
|
1051
1105
|
const known = stated !== undefined;
|
|
1052
|
-
const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
|
|
1106
|
+
const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) }, { discover: discover && !known });
|
|
1053
1107
|
if (stated && measured.wire)
|
|
1054
1108
|
measured.effortLevels = stated;
|
|
1055
1109
|
job.results.push(measured);
|
|
@@ -1341,13 +1395,8 @@ export function startAdmin(deps) {
|
|
|
1341
1395
|
sendJson(res, 400, { error: `provider "${body.provider}" is not openai-compatible or anthropic-compatible` });
|
|
1342
1396
|
return;
|
|
1343
1397
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const job = { state: "running", done: 0, total: body.models.length, results: [] };
|
|
1347
|
-
MEASURE_JOBS.set(jobId, job);
|
|
1348
|
-
deps.log.info(`admin: measuring ${body.provider} (${body.models.length} model(s)) -> ${jobId.slice(0, 8)}`);
|
|
1349
|
-
void runMeasureJob(jobId, body.provider, body.models, deps);
|
|
1350
|
-
sendJson(res, 202, { jobId, total: job.total });
|
|
1398
|
+
const jobId = startMeasureJob(body.provider, body.models, deps);
|
|
1399
|
+
sendJson(res, 202, { jobId, total: body.models.length });
|
|
1351
1400
|
return;
|
|
1352
1401
|
}
|
|
1353
1402
|
if (pathname.startsWith("/api/providers/measure/") && method === "GET") {
|
|
@@ -136,12 +136,63 @@ async function candidateLevels(id, candidate, ladder, deps) {
|
|
|
136
136
|
listed = parseLevels(await response.text());
|
|
137
137
|
}
|
|
138
138
|
catch { /* the endpoint said nothing; fall back to everything known */ }
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
return canonicalOrder(listed.length > 0 ? listed : [...ladder, ...KNOWN_EFFORTS]);
|
|
140
|
+
}
|
|
141
|
+
/** Canonical order first so the GUI reads weakest to strongest, then anything newly named. */
|
|
142
|
+
function canonicalOrder(levels) {
|
|
143
|
+
const seen = new Set(levels);
|
|
144
|
+
return [...KNOWN_EFFORTS.filter((level) => seen.has(level)), ...levels.filter((level) => !KNOWN_EFFORTS.includes(level))]
|
|
143
145
|
.filter((level, index, all) => all.indexOf(level) === index);
|
|
144
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether a model with no declared ladder takes reasoning effort at all, and which levels.
|
|
149
|
+
*
|
|
150
|
+
* A provider set up from a key and a URL declares no ladder, and without one every effort the user
|
|
151
|
+
* picked was stripped and the app showed no effort menu — for DeepSeek's API, which takes effort,
|
|
152
|
+
* among others. Whether a model takes effort is not knowable from its id, and "the endpoint
|
|
153
|
+
* accepted the field" is not proof either: an endpoint that ignores unknown fields accepts any
|
|
154
|
+
* value. So a value no vendor defines is sent first:
|
|
155
|
+
*
|
|
156
|
+
* - accepted → the field is not read; no effort (`[]`).
|
|
157
|
+
* - refused (400/422) → the field is validated. The levels the refusal names, else every known one,
|
|
158
|
+
* are sent one at a time, and the accepted ones are the ladder. DeepSeek refuses with the list
|
|
159
|
+
* `none … max` and accepts each (measured 2026-10-06).
|
|
160
|
+
* - anything else (429, 5xx, network) → nothing is concluded (`undefined`), so a later save measures
|
|
161
|
+
* again. Likewise when a level could not be settled, or no level was accepted while one could not
|
|
162
|
+
* be asked: a partial ladder from a hiccup would read as the model's own.
|
|
163
|
+
*/
|
|
164
|
+
async function discoverLadder(id, candidate, deps) {
|
|
165
|
+
const url = endpointFor(candidate.wire, candidate.url);
|
|
166
|
+
let probe;
|
|
167
|
+
try {
|
|
168
|
+
probe = await deps.fetch(url, { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id, NOT_A_LEVEL) });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
if (probe.ok)
|
|
174
|
+
return [];
|
|
175
|
+
if (!refusedLevel(probe.status))
|
|
176
|
+
return undefined;
|
|
177
|
+
const listed = parseLevels(await probe.text().catch(() => ""));
|
|
178
|
+
const accepted = [];
|
|
179
|
+
let unsettled = false;
|
|
180
|
+
for (const level of canonicalOrder(listed.length > 0 ? listed : KNOWN_EFFORTS)) {
|
|
181
|
+
let response;
|
|
182
|
+
try {
|
|
183
|
+
response = await deps.fetch(url, { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id, level) });
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
unsettled = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (response.ok)
|
|
190
|
+
accepted.push(level);
|
|
191
|
+
else if (!refusedLevel(response.status))
|
|
192
|
+
unsettled = true;
|
|
193
|
+
}
|
|
194
|
+
return unsettled ? undefined : accepted;
|
|
195
|
+
}
|
|
145
196
|
/** The levels an endpoint listed in its complaint, or nothing when it did not list any. */
|
|
146
197
|
export function parseLevels(message) {
|
|
147
198
|
const listed = /supported values:\s*\[([^\]]+)\]/i.exec(message)?.[1] ?? /expected one of\s+(.+?)(?:\s+at line\b|$)/i.exec(message)?.[1];
|
|
@@ -181,8 +232,11 @@ async function measureLadder(id, candidate, ladder, deps) {
|
|
|
181
232
|
* was refused before the path mattered, so reporting anything but "auth" would send the user to
|
|
182
233
|
* check the wire when the problem is the credential. When no candidate answers, the result carries
|
|
183
234
|
* the last status and body as `error` and no `wire`.
|
|
235
|
+
*
|
|
236
|
+
* `discover` is for a model with no ladder from anywhere: an empty `ladder` then means "find out"
|
|
237
|
+
* (`discoverLadder`) rather than "do not measure effort".
|
|
184
238
|
*/
|
|
185
|
-
export async function measureModel(id, candidates, ladder, deps) {
|
|
239
|
+
export async function measureModel(id, candidates, ladder, deps, options = {}) {
|
|
186
240
|
if (candidates.length === 0)
|
|
187
241
|
return { id, error: "no candidate wire to try" };
|
|
188
242
|
let failure = "no candidate wire answered";
|
|
@@ -204,8 +258,12 @@ export async function measureModel(id, candidates, ladder, deps) {
|
|
|
204
258
|
return { id, error: refusedByPlan(detail) ? `not-entitled: ${detail}` : "auth" };
|
|
205
259
|
}
|
|
206
260
|
if (response.ok) {
|
|
207
|
-
if (ladder.length === 0)
|
|
208
|
-
|
|
261
|
+
if (ladder.length === 0) {
|
|
262
|
+
if (!options.discover)
|
|
263
|
+
return { id, wire: candidate.wire };
|
|
264
|
+
const discovered = await discoverLadder(id, candidate, deps);
|
|
265
|
+
return { id, wire: candidate.wire, ...(discovered !== undefined ? { effortLevels: discovered } : {}) };
|
|
266
|
+
}
|
|
209
267
|
return { id, wire: candidate.wire, effortLevels: await measureLadder(id, candidate, ladder, deps) };
|
|
210
268
|
}
|
|
211
269
|
failure = `${response.status} ${snippet(await response.text())}`.trim();
|
package/dist/router/src/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { CertStore } from "./certs.js";
|
|
|
14
14
|
import { Logger } from "./log.js";
|
|
15
15
|
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
|
|
16
16
|
import { Proxy } from "./proxy.js";
|
|
17
|
-
import { startAdmin } from "./admin.js";
|
|
17
|
+
import { measureUnsettledModels, startAdmin } from "./admin.js";
|
|
18
18
|
import { loadModelCatalog } from "./catalog.js";
|
|
19
19
|
import { RequestLog } from "./requestlog.js";
|
|
20
20
|
import { OpenAiIngress } from "./ingress/server.js";
|
|
@@ -138,7 +138,7 @@ proxy
|
|
|
138
138
|
// Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
|
|
139
139
|
// (2026-09-20). Ask once now, after listen(), and never wait for the answer.
|
|
140
140
|
proxy.chatgptRefreshAll();
|
|
141
|
-
const
|
|
141
|
+
const adminDeps = {
|
|
142
142
|
config: () => store.get(),
|
|
143
143
|
configFile: configPath(),
|
|
144
144
|
log: log,
|
|
@@ -167,9 +167,13 @@ proxy
|
|
|
167
167
|
image: (req, signal) => proxy.generateImage(req, signal),
|
|
168
168
|
modelCatalog: () => loadModelCatalog(),
|
|
169
169
|
shutdown: () => beginDrain("shutdown requested"),
|
|
170
|
-
}
|
|
170
|
+
};
|
|
171
|
+
const admin = await startAdmin(adminDeps);
|
|
171
172
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
172
173
|
setImmediate(premintLeaves);
|
|
174
|
+
// A minute in, once start-up traffic has settled: a few one-token requests per model whose
|
|
175
|
+
// effort nobody has measured yet, so effort works without the owner re-saving the provider.
|
|
176
|
+
setTimeout(() => measureUnsettledModels(adminDeps), 60_000).unref();
|
|
173
177
|
})
|
|
174
178
|
.catch((e) => {
|
|
175
179
|
log.error(`listen failed: ${e.message}`);
|
|
@@ -14,9 +14,12 @@ export const PRESETS = [
|
|
|
14
14
|
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
|
15
15
|
{ id: "deepseek-flash", name: "DeepSeek Flash" },
|
|
16
16
|
],
|
|
17
|
-
// https://api-docs.deepseek.com/guides/
|
|
18
|
-
//
|
|
19
|
-
|
|
17
|
+
// https://api-docs.deepseek.com/guides/thinking_mode/ maps every requested effort onto three:
|
|
18
|
+
// minimal/low → low, medium/high/xhigh → high, max/ultra → max (thinking on, high, by default).
|
|
19
|
+
// Measured 2026-10-06 on this endpoint: `output_config.effort` is validated (an unknown value is
|
|
20
|
+
// 422 "expected one of none … max") and low/high/max answer 200 on deepseek-v4-pro and
|
|
21
|
+
// deepseek-flash. Offering the three real levels keeps the menu from showing steps that collapse.
|
|
22
|
+
effortLevels: ["low", "high", "max"],
|
|
20
23
|
thinking: "enabled",
|
|
21
24
|
// Measured 2026-09-17, not documented: a `web_search_20250305` tool sent to this endpoint came
|
|
22
25
|
// back with server_tool_use, a web_search_tool_result holding ten hits, and
|
|
@@ -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.2";
|
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,9 @@ 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
|
+
| An API-key provider with no declared effort ladder — DeepSeek, Kimi, GLM, MiniMax, Qwen, Groq, Mistral, a key and a URL — stripped every effort the user picked (`effort high→removed`) and the Claude app showed no effort menu for its models (DC reports, 2026-10-06). Declaring nothing was meant as "do not guess", but DeepSeek's API, for one, validates `output_config.effort` and maps it onto low/high/max | When nothing declares a ladder (no config ladder, no preset ladder, OpenAI-compatible `reasoning` not `effort` — the form writes `none` by default), a measurement sends a level no vendor defines first: accepted means the field is ignored (no effort, `[]`); refused means it is validated, and the levels the refusal names (else every known one) are sent one by one and the accepted ones become the model's ladder. A 429/5xx concludes nothing. The router runs this a minute after start for unsettled models, so existing providers need no re-save. The DeepSeek preset now declares low/high/max from its docs. |
|
|
1142
|
+
| 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
1143
|
|
|
1125
1144
|
## 6. Blocked paths (measured, do not retry)
|
|
1126
1145
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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": [
|