clauderipple 0.8.1 → 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 +14 -0
- package/dist/app/package.json +1 -1
- 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/version.js +1 -1
- package/docs/ARCHITECTURE.md +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.8.1 — 2026-10-06
|
|
4
18
|
|
|
5
19
|
### Fixed
|
package/dist/app/package.json
CHANGED
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
|
|
@@ -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
|
@@ -1138,6 +1138,7 @@ subscription exists in this environment. Files are under `src/`:
|
|
|
1138
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`. |
|
|
1139
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
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. |
|
|
1141
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. |
|
|
1142
1143
|
|
|
1143
1144
|
## 6. Blocked paths (measured, do not retry)
|
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": [
|