clauderipple 0.5.1 → 0.7.0
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 +45 -0
- package/README.ko.md +33 -4
- package/README.md +42 -4
- package/dist/app/dist/main.js +124 -4
- package/dist/app/package.json +1 -0
- package/dist/cli/src/index.js +110 -1
- package/dist/cli/src/tray.js +21 -7
- package/dist/cli/src/update.js +104 -0
- package/dist/router/src/admin.js +118 -4
- package/dist/router/src/agents.js +28 -3
- package/dist/router/src/config.js +2 -0
- package/dist/router/src/index.js +2 -0
- package/dist/router/src/providers/anthropic-usage.js +98 -0
- package/dist/router/src/providers/chatgpt/index.js +112 -0
- package/dist/router/src/proxy.js +21 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +40 -2
- package/dist/ui/i18n.js +12 -0
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +1 -0
- package/docs/ARCHITECTURE.md +15 -1
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `clauderipple update`: bring this installation to the latest published version, the way it
|
|
2
|
+
// was installed.
|
|
3
|
+
//
|
|
4
|
+
// Until now an update meant running the install command again by hand, and the tray could not
|
|
5
|
+
// offer it (#39). What "again" means depends on how ClaudeRipple arrived:
|
|
6
|
+
// * The install scripts put it under their own prefix (~/.clauderipple by default) with their own
|
|
7
|
+
// command shims — a wrapper naming the Node they downloaded, and on Windows no .ps1. A plain
|
|
8
|
+
// `npm install -g` would put npm's default shims back, which find no Node on a machine where
|
|
9
|
+
// the script's Node is the only one. So a script install is updated by running its script.
|
|
10
|
+
// * An `npm install -g` is updated by npm, into the same prefix.
|
|
11
|
+
// * A packaged app or a source checkout is not updated from here.
|
|
12
|
+
// Either way `install` runs afterwards from the new files; it restarts a router still running
|
|
13
|
+
// the old version, which re-running the installer never did before.
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { windowsPowerShellEnv } from "./windows-powershell.js";
|
|
19
|
+
export const PACKAGE = "clauderipple";
|
|
20
|
+
const SCRIPT_BASE = "https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts";
|
|
21
|
+
/** Numeric x.y.z comparison; a pre-release suffix is ignored. */
|
|
22
|
+
export function isNewer(latest, current) {
|
|
23
|
+
const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map((n) => Number(n) || 0);
|
|
24
|
+
const a = parse(latest);
|
|
25
|
+
const b = parse(current);
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
28
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/** The default prefix of both install scripts, or the one they were told to use. */
|
|
33
|
+
export function scriptPrefix(env = process.env, home = os.homedir()) {
|
|
34
|
+
return env.CLAUDERIPPLE_PREFIX || path.join(home, ".clauderipple");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* How the running copy was installed, read from where its files are. A global npm package lives
|
|
38
|
+
* at <prefix>/lib/node_modules/<name> on macOS and Linux and <prefix>\node_modules\<name> on Windows.
|
|
39
|
+
*/
|
|
40
|
+
export function installKind(rt, opts = {}) {
|
|
41
|
+
if (rt.packaged)
|
|
42
|
+
return { kind: "packaged" };
|
|
43
|
+
if (rt.cli.endsWith(".ts"))
|
|
44
|
+
return { kind: "checkout" };
|
|
45
|
+
const p = (opts.platform ?? process.platform) === "win32" ? path.win32 : path.posix;
|
|
46
|
+
const root = rt.repo;
|
|
47
|
+
const modules = p.dirname(root);
|
|
48
|
+
if (p.basename(modules) !== "node_modules")
|
|
49
|
+
return { kind: "unknown", root };
|
|
50
|
+
const parent = p.dirname(modules);
|
|
51
|
+
const prefix = (opts.platform ?? process.platform) === "win32" ? parent : p.basename(parent) === "lib" ? p.dirname(parent) : null;
|
|
52
|
+
if (!prefix)
|
|
53
|
+
return { kind: "unknown", root };
|
|
54
|
+
const same = (a, b) => (opts.platform ?? process.platform) === "win32" ? p.resolve(a).toLowerCase() === p.resolve(b).toLowerCase() : p.resolve(a) === p.resolve(b);
|
|
55
|
+
return same(prefix, scriptPrefix(opts.env, opts.home)) ? { kind: "script", prefix } : { kind: "npm", prefix };
|
|
56
|
+
}
|
|
57
|
+
/** The version npm would install now. */
|
|
58
|
+
export async function latestVersion(fetchImpl = fetch) {
|
|
59
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
throw new Error(`npm registry answered HTTP ${res.status}`);
|
|
62
|
+
const version = (await res.json()).version;
|
|
63
|
+
if (typeof version !== "string")
|
|
64
|
+
throw new Error("npm registry answered without a version");
|
|
65
|
+
return version;
|
|
66
|
+
}
|
|
67
|
+
/** The npm that came with the Node running us, as a script that same Node can run. */
|
|
68
|
+
function npmCli() {
|
|
69
|
+
const candidates = [
|
|
70
|
+
path.resolve(path.dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
|
|
71
|
+
path.resolve(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"),
|
|
72
|
+
];
|
|
73
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Installs `spec` over this installation and runs `install` from the new files. Output goes to
|
|
77
|
+
* our stdout as it happens, so the tray's dialog and a terminal show the installer's own lines.
|
|
78
|
+
*/
|
|
79
|
+
export function runUpdate(target, rt, spec) {
|
|
80
|
+
const env = { ...process.env, CLAUDERIPPLE_PREFIX: target.prefix, CLAUDERIPPLE_PACKAGE: spec };
|
|
81
|
+
if (target.kind === "script") {
|
|
82
|
+
// A local path stands in for the published script when checking an update before release.
|
|
83
|
+
const script = process.env.CLAUDERIPPLE_INSTALLER;
|
|
84
|
+
const result = process.platform === "win32"
|
|
85
|
+
? spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script ? `& ${psQuote(script)}` : `irm ${SCRIPT_BASE}/install.ps1 | iex`], { env: { ...windowsPowerShellEnv(), ...env }, stdio: "inherit", windowsHide: true })
|
|
86
|
+
: spawnSync("sh", ["-c", script ? `sh ${shQuote(script)}` : `curl -fsSL ${SCRIPT_BASE}/install.sh | sh`], { env, stdio: "inherit" });
|
|
87
|
+
// The script ends by running `install` itself.
|
|
88
|
+
return result.status === 0 ? { ok: true, message: "✓ updated" } : { ok: false, message: `the installer exited with ${result.status ?? result.signal}` };
|
|
89
|
+
}
|
|
90
|
+
const npm = npmCli();
|
|
91
|
+
if (!npm)
|
|
92
|
+
return { ok: false, message: `found no npm next to ${process.execPath}; update with: npm install -g ${PACKAGE}@latest` };
|
|
93
|
+
const installed = spawnSync(process.execPath, [npm, "install", "--global", "--prefix", target.prefix, "--loglevel", "error", spec], { env, stdio: "inherit" });
|
|
94
|
+
if (installed.status !== 0)
|
|
95
|
+
return { ok: false, message: `npm could not install ${spec}` };
|
|
96
|
+
const setup = spawnSync(rt.node, [rt.cli, "install"], { env: { ...process.env, ...rt.env }, stdio: "inherit" });
|
|
97
|
+
return setup.status === 0 ? { ok: true, message: "✓ updated" } : { ok: false, message: "the new version is installed, but `clauderipple install` did not finish" };
|
|
98
|
+
}
|
|
99
|
+
function psQuote(value) {
|
|
100
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
101
|
+
}
|
|
102
|
+
function shQuote(value) {
|
|
103
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
104
|
+
}
|
package/dist/router/src/admin.js
CHANGED
|
@@ -33,7 +33,10 @@ import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
|
33
33
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
34
34
|
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
35
35
|
import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
|
|
36
|
+
import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
|
|
36
37
|
const MAX_BODY = 1024 * 1024;
|
|
38
|
+
/** `/api/image` carries reference images inline, so it gets more room than a config save. */
|
|
39
|
+
const MAX_IMAGE_BODY = 32 * 1024 * 1024;
|
|
37
40
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
38
41
|
const STARTED_AT = new Date().toISOString();
|
|
39
42
|
// packages/router/src → packages/ui in a checkout; dist/router/src → dist/ui in an npm install,
|
|
@@ -48,6 +51,32 @@ const MIME = {
|
|
|
48
51
|
".png": "image/png",
|
|
49
52
|
".ico": "image/x-icon",
|
|
50
53
|
};
|
|
54
|
+
/** Claude usage is looked up only where a Claude subscription provider is configured. */
|
|
55
|
+
function hasClaudeSubscription(cfg) {
|
|
56
|
+
return Object.values(cfg.providers).some((p) => p.type === "anthropic" && p.auth === "claude-code");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One row per Claude account with a known usage, current login first. Labels only: the status is
|
|
60
|
+
* polled every few seconds and has no use for an email address.
|
|
61
|
+
*/
|
|
62
|
+
async function claudeAccountUsage(deps) {
|
|
63
|
+
let usage;
|
|
64
|
+
try {
|
|
65
|
+
usage = (await deps.claudeUsage?.()) ?? {};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const rows = [];
|
|
71
|
+
if (usage.current)
|
|
72
|
+
rows.push({ id: "current", label: "Current Claude login", quota: usage.current });
|
|
73
|
+
for (const account of listClaudeAccounts(homeDir())) {
|
|
74
|
+
const quota = usage[account.id];
|
|
75
|
+
if (quota)
|
|
76
|
+
rows.push({ id: account.id, label: account.label, quota });
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
51
80
|
const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
|
|
52
81
|
const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
53
82
|
let chatgptLogin = { running: false };
|
|
@@ -317,6 +346,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
317
346
|
// `quota` stays one snapshot per provider (the account in use) for the readers that want one
|
|
318
347
|
// number; `accounts` is the per-account view, read after the refresh above so it is as fresh.
|
|
319
348
|
chatgpt: { ...chatgpt, signedIn, accounts: chatgpt.accounts?.() ?? {}, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
|
|
349
|
+
...(deps.claudeUsage && hasClaudeSubscription(cfg) ? { claude: { accounts: await claudeAccountUsage(deps) } } : {}),
|
|
320
350
|
// Only present for providers that declare a pool; a provider with one credential has nothing
|
|
321
351
|
// to report and would only add a row that never changes.
|
|
322
352
|
credentials: deps.credentials?.() ?? {},
|
|
@@ -417,13 +447,51 @@ function syncModelSlotsForGui(cfg) {
|
|
|
417
447
|
return `model slots were not written to settings.json: ${message}`;
|
|
418
448
|
}
|
|
419
449
|
}
|
|
420
|
-
|
|
450
|
+
/**
|
|
451
|
+
* The body of `POST /api/image`, checked: `{prompt, aspect?, format?, background?, images?}`,
|
|
452
|
+
* `images` being `data:image/...;base64,` URLs. A string is the reason it was refused.
|
|
453
|
+
*/
|
|
454
|
+
export function parseImageRequest(raw) {
|
|
455
|
+
if (!raw || typeof raw !== "object")
|
|
456
|
+
return "expected a JSON object";
|
|
457
|
+
const body = raw;
|
|
458
|
+
if (typeof body.prompt !== "string" || !body.prompt.trim())
|
|
459
|
+
return "prompt is required";
|
|
460
|
+
const out = { prompt: body.prompt };
|
|
461
|
+
const pick = (key, allowed) => {
|
|
462
|
+
const value = body[key];
|
|
463
|
+
if (value === undefined)
|
|
464
|
+
return null;
|
|
465
|
+
if (typeof value !== "string" || !allowed.includes(value))
|
|
466
|
+
return `${key} must be one of ${allowed.join(", ")}`;
|
|
467
|
+
out[key] = value;
|
|
468
|
+
return null;
|
|
469
|
+
};
|
|
470
|
+
const refused = pick("aspect", IMAGE_ASPECTS) ?? pick("format", IMAGE_FORMATS) ?? pick("background", IMAGE_BACKGROUNDS);
|
|
471
|
+
if (refused)
|
|
472
|
+
return refused;
|
|
473
|
+
if (body.images !== undefined) {
|
|
474
|
+
if (!Array.isArray(body.images))
|
|
475
|
+
return "images must be a list of data: URLs";
|
|
476
|
+
const images = [];
|
|
477
|
+
for (const url of body.images) {
|
|
478
|
+
const m = typeof url === "string" ? /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/i.exec(url) : null;
|
|
479
|
+
if (!m)
|
|
480
|
+
return "each image must be a data:image/...;base64, URL";
|
|
481
|
+
images.push({ mediaType: m[1].toLowerCase(), data: m[2].replace(/\s/g, "") });
|
|
482
|
+
}
|
|
483
|
+
if (images.length)
|
|
484
|
+
out.images = images;
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
function readBody(req, limit = MAX_BODY) {
|
|
421
489
|
return new Promise((resolveP, reject) => {
|
|
422
490
|
const chunks = [];
|
|
423
491
|
let n = 0;
|
|
424
492
|
req.on("data", (c) => {
|
|
425
493
|
n += c.length;
|
|
426
|
-
if (n >
|
|
494
|
+
if (n > limit) {
|
|
427
495
|
reject(new Error("body too large"));
|
|
428
496
|
req.destroy();
|
|
429
497
|
return;
|
|
@@ -1236,9 +1304,12 @@ export function startAdmin(deps) {
|
|
|
1236
1304
|
// ClaudeRipple accounts can be renamed or removed by their local opaque id.
|
|
1237
1305
|
if (pathname === "/api/claude-accounts" && method === "GET") {
|
|
1238
1306
|
const source = claudeAuthStore(deps).describeSource();
|
|
1307
|
+
let usage = {};
|
|
1308
|
+
if (hasClaudeSubscription(deps.config()))
|
|
1309
|
+
usage = (await deps.claudeUsage?.().catch(() => null)) ?? {};
|
|
1239
1310
|
sendJson(res, 200, {
|
|
1240
|
-
current: source ? { id: "current", label: "Current Claude login", source, external: true } : null,
|
|
1241
|
-
accounts: listClaudeAccounts(homeDir()),
|
|
1311
|
+
current: source ? { id: "current", label: "Current Claude login", source, external: true, ...(usage.current ? { quota: usage.current } : {}) } : null,
|
|
1312
|
+
accounts: listClaudeAccounts(homeDir()).map((account) => (usage[account.id] ? { ...account, quota: usage[account.id] } : account)),
|
|
1242
1313
|
});
|
|
1243
1314
|
return;
|
|
1244
1315
|
}
|
|
@@ -1379,6 +1450,49 @@ export function startAdmin(deps) {
|
|
|
1379
1450
|
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, output: r.output });
|
|
1380
1451
|
return;
|
|
1381
1452
|
}
|
|
1453
|
+
if (pathname === "/api/image" && method === "POST") {
|
|
1454
|
+
// The image itself is the response body, so `curl -o out.png` is the whole client. An error
|
|
1455
|
+
// is JSON with a non-2xx status (`curl --fail-with-body` writes it to the same file).
|
|
1456
|
+
if (!deps.image) {
|
|
1457
|
+
sendJson(res, 501, { error: "image generation not available" });
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
let parsed;
|
|
1461
|
+
try {
|
|
1462
|
+
parsed = parseImageRequest(JSON.parse((await readBody(req, MAX_IMAGE_BODY)).toString("utf8")));
|
|
1463
|
+
}
|
|
1464
|
+
catch (e) {
|
|
1465
|
+
sendJson(res, 400, { error: /too large/.test(e.message) ? "body too large (reference images over 32 MB)" : "invalid JSON" });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
if (typeof parsed === "string") {
|
|
1469
|
+
sendJson(res, 400, { error: parsed });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
// A caller that gives up should not leave the generation running on the subscription.
|
|
1473
|
+
const abort = new AbortController();
|
|
1474
|
+
res.on("close", () => { if (!res.writableFinished)
|
|
1475
|
+
abort.abort(); });
|
|
1476
|
+
const started = Date.now();
|
|
1477
|
+
try {
|
|
1478
|
+
const image = await deps.image(parsed, abort.signal);
|
|
1479
|
+
deps.log.info(`admin: image ${image.size ?? "?"} ${image.quality ?? "?"} ${image.format}, ${parsed.images?.length ?? 0} reference(s), ${image.data.length} bytes in ${Date.now() - started} ms`);
|
|
1480
|
+
res.writeHead(200, {
|
|
1481
|
+
"content-type": `image/${image.format}`,
|
|
1482
|
+
"content-length": String(image.data.length),
|
|
1483
|
+
...(image.size ? { "x-image-size": image.size } : {}),
|
|
1484
|
+
...(image.quality ? { "x-image-quality": image.quality } : {}),
|
|
1485
|
+
...(image.revisedPrompt ? { "x-revised-prompt": encodeURIComponent(image.revisedPrompt) } : {}),
|
|
1486
|
+
});
|
|
1487
|
+
res.end(image.data);
|
|
1488
|
+
}
|
|
1489
|
+
catch (e) {
|
|
1490
|
+
deps.log.warn(`admin: image failed after ${Date.now() - started} ms: ${e.message}`);
|
|
1491
|
+
if (!res.headersSent)
|
|
1492
|
+
sendJson(res, 502, { error: e.message });
|
|
1493
|
+
}
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1382
1496
|
if (pathname === "/api/agent-title" && method === "POST") {
|
|
1383
1497
|
// Registers/removes the PreToolUse hook that prefixes subagent titles with the real model.
|
|
1384
1498
|
let enabled;
|
|
@@ -14,6 +14,7 @@ import os from "node:os";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { homeDir } from "./config.js";
|
|
16
16
|
import { declaredBy } from "./routing.js";
|
|
17
|
+
import { adminPort } from "./admin.js";
|
|
17
18
|
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
18
19
|
export function defaultAgentDir() {
|
|
19
20
|
return path.join(os.homedir(), ".claude", "agents");
|
|
@@ -34,6 +35,27 @@ const BODY = [
|
|
|
34
35
|
"**작업 디렉터리(cwd)에 어떤 파일도 만들지 않는다.** 조사·감사처럼 \"읽기만\" 하는 일이어도 마찬가지다.",
|
|
35
36
|
"임시 파일이 필요하면 `/tmp` 아래에만 만들고, 끝나면 지운다. 사본을 작업 폴더에 떨구지 마라.",
|
|
36
37
|
].join("\n");
|
|
38
|
+
/**
|
|
39
|
+
* Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
|
|
40
|
+
* no image tool, and `clauderipple` is not on PATH for everyone (a checkout runs from source), so
|
|
41
|
+
* the instruction is the admin endpoint itself, which curl reaches on every platform.
|
|
42
|
+
*/
|
|
43
|
+
function imageLine(port) {
|
|
44
|
+
return [
|
|
45
|
+
"이미지가 필요하면 ChatGPT 구독으로 만든다(한 장 30초~수 분):",
|
|
46
|
+
`\`curl -sS --fail-with-body -o <절대경로>.png -H 'content-type: application/json' -d '{"prompt":"…"}' http://127.0.0.1:${port}/api/image\``,
|
|
47
|
+
"선택 키: aspect(square·landscape·portrait), background(transparent), format(png·jpeg·webp), images(참고 이미지 data: URL 목록). 해상도·품질은 지정할 수 없다. 실패하면 그 파일에 오류 JSON이 담긴다. 만든 뒤 Read로 확인한다.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The tools a generated worker is given when `cli.limitWorkerTools` is on. Without a `tools:` line a
|
|
52
|
+
* subagent inherits every tool of the session — each MCP server, plugin agent and deferred-tool name —
|
|
53
|
+
* and a general worker's first request measured 43,598 tokens (2026-09-20), resent on every turn;
|
|
54
|
+
* limited, a deepseek worker's first request fell from 32,942 to 8,823 (2026-09-29). A worker reads,
|
|
55
|
+
* edits, searches, runs commands and uses skills; it does not drive browsers, simulators or mail.
|
|
56
|
+
* Off by default: a user whose workers do need those tools would otherwise lose them silently.
|
|
57
|
+
*/
|
|
58
|
+
const TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "WebSearch", "Skill"];
|
|
37
59
|
/**
|
|
38
60
|
* Parsed `*.md` frontmatter per directory, keyed by file mtime so a request never re-reads the whole
|
|
39
61
|
* directory. Only the `---` block's own `key: value` lines are read: no YAML library, because the
|
|
@@ -134,10 +156,12 @@ function effortSuffix(provider, modelId) {
|
|
|
134
156
|
const levels = modelLevels !== undefined ? modelLevels : capsEffortLevels(provider);
|
|
135
157
|
return levels?.includes("medium") ? "@medium" : "";
|
|
136
158
|
}
|
|
137
|
-
function fileContent(name, modelId, provider, suffix) {
|
|
159
|
+
function fileContent(name, modelId, provider, suffix, limitTools, imagePort) {
|
|
138
160
|
const description = `${modelId} via ${provider}. Generated by ClaudeRipple from config.json — edits are overwritten; ` +
|
|
139
161
|
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line.`;
|
|
140
|
-
|
|
162
|
+
const tools = limitTools ? `tools: ${TOOLS.join(", ")}\n` : "";
|
|
163
|
+
const body = imagePort === null ? BODY : `${BODY}\n${imageLine(imagePort)}`;
|
|
164
|
+
return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n${tools}---\n${body}\n`;
|
|
141
165
|
}
|
|
142
166
|
function readManifest(file) {
|
|
143
167
|
try {
|
|
@@ -199,6 +223,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
199
223
|
targets.set(name, { modelId: model.id, provider: providerName, suffix: effortSuffix(provider, model.id) });
|
|
200
224
|
}
|
|
201
225
|
}
|
|
226
|
+
const imagePort = Object.values(cfg.providers).some((p) => p.type === "chatgpt") ? adminPort(cfg) : null;
|
|
202
227
|
const nextOwned = [];
|
|
203
228
|
for (const [name, t] of targets) {
|
|
204
229
|
if (handNames.has(name)) {
|
|
@@ -206,7 +231,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
206
231
|
continue;
|
|
207
232
|
}
|
|
208
233
|
const file = path.join(dir, `${name}.md`);
|
|
209
|
-
const content = fileContent(name, t.modelId, t.provider, t.suffix);
|
|
234
|
+
const content = fileContent(name, t.modelId, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
|
|
210
235
|
const existing = scanned.get(`${name}.md`);
|
|
211
236
|
if (existing) {
|
|
212
237
|
let current = null;
|
|
@@ -381,6 +381,8 @@ export function validate(c) {
|
|
|
381
381
|
errors.push("listen.port out of range");
|
|
382
382
|
if (c.cli.agentFiles !== undefined && typeof c.cli.agentFiles !== "boolean")
|
|
383
383
|
errors.push("cli.agentFiles must be true or false");
|
|
384
|
+
if (c.cli.limitWorkerTools !== undefined && typeof c.cli.limitWorkerTools !== "boolean")
|
|
385
|
+
errors.push("cli.limitWorkerTools must be true or false");
|
|
384
386
|
if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
|
|
385
387
|
errors.push("listen.openaiPort out of range");
|
|
386
388
|
return errors;
|
package/dist/router/src/index.js
CHANGED
|
@@ -153,6 +153,8 @@ proxy
|
|
|
153
153
|
credentials: () => proxy.credentialHealth(),
|
|
154
154
|
picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
|
|
155
155
|
observedClaudeCodeAuth,
|
|
156
|
+
claudeUsage: () => proxy.claudeUsage.snapshot(),
|
|
157
|
+
image: (req, signal) => proxy.generateImage(req, signal),
|
|
156
158
|
shutdown: () => beginDrain("shutdown requested"),
|
|
157
159
|
});
|
|
158
160
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// How much of each Claude subscription account's limits is used, for the dashboard and the tray.
|
|
2
|
+
//
|
|
3
|
+
// Response headers would only describe the account that last answered, so an idle second account
|
|
4
|
+
// would never show a number. Claude Code's own `/usage` asks `GET /api/oauth/usage` with the
|
|
5
|
+
// account's OAuth headers instead, and so does this. Shape measured 2026-09-28 (CLI 2.1.281):
|
|
6
|
+
// {"five_hour": {"utilization": 14.0, "resets_at": "2026-09-28T08:30:00.581181+00:00", …},
|
|
7
|
+
// "seven_day": {"utilization": 10.0, "resets_at": "…"}, "seven_day_opus": null, …}
|
|
8
|
+
// `utilization` is a percentage. It is mapped onto the Codex snapshot shape
|
|
9
|
+
// (`rate_limits.primary/secondary` with `used_percent`, `window_minutes`, `reset_at` in seconds)
|
|
10
|
+
// so the GUI and the tray read one format for every account.
|
|
11
|
+
const FIVE_HOURS = 5 * 60;
|
|
12
|
+
const WEEK = 7 * 24 * 60;
|
|
13
|
+
export function claudeUsageFromBody(body) {
|
|
14
|
+
const b = body;
|
|
15
|
+
const win = (w, minutes) => {
|
|
16
|
+
if (!w || typeof w.utilization !== "number" || !Number.isFinite(w.utilization))
|
|
17
|
+
return null;
|
|
18
|
+
const out = { used_percent: w.utilization, window_minutes: minutes };
|
|
19
|
+
const at = typeof w.resets_at === "string" ? Date.parse(w.resets_at) : NaN;
|
|
20
|
+
if (Number.isFinite(at))
|
|
21
|
+
out.reset_at = Math.round(at / 1000);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
const primary = win(b?.five_hour, FIVE_HOURS);
|
|
25
|
+
const secondary = win(b?.seven_day, WEEK);
|
|
26
|
+
if (!primary && !secondary)
|
|
27
|
+
return null;
|
|
28
|
+
return { type: "claude.usage", rate_limits: { primary: primary ?? secondary, secondary: primary ? secondary : null }, at: Date.now() };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Per-account snapshots, fetched at most once per `ttlMs` per account. The GUI polls the status
|
|
32
|
+
* every five seconds and the tray as often; a limit that moves by a percent in minutes does not
|
|
33
|
+
* need more. A failed lookup keeps the last good numbers rather than blanking them.
|
|
34
|
+
*/
|
|
35
|
+
export class ClaudeUsage {
|
|
36
|
+
last = new Map();
|
|
37
|
+
inFlight = null;
|
|
38
|
+
fetchedAt = Number.NEGATIVE_INFINITY;
|
|
39
|
+
source;
|
|
40
|
+
opts;
|
|
41
|
+
constructor(source, opts) {
|
|
42
|
+
this.source = source;
|
|
43
|
+
this.opts = opts;
|
|
44
|
+
}
|
|
45
|
+
/** Snapshots by account owner id ("current" for the Claude Code login). */
|
|
46
|
+
snapshot() {
|
|
47
|
+
const now = (this.opts.now ?? Date.now)();
|
|
48
|
+
if (now - this.fetchedAt < (this.opts.ttlMs ?? 5 * 60_000))
|
|
49
|
+
return Promise.resolve(Object.fromEntries(this.last));
|
|
50
|
+
this.inFlight ??= this.fetchAll().finally(() => {
|
|
51
|
+
this.fetchedAt = (this.opts.now ?? Date.now)();
|
|
52
|
+
this.inFlight = null;
|
|
53
|
+
});
|
|
54
|
+
return this.inFlight;
|
|
55
|
+
}
|
|
56
|
+
async fetchAll() {
|
|
57
|
+
let credentials;
|
|
58
|
+
try {
|
|
59
|
+
credentials = await this.source.credentials();
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
this.opts.warn?.(`claude usage: no credentials: ${e.message}`);
|
|
63
|
+
return Object.fromEntries(this.last);
|
|
64
|
+
}
|
|
65
|
+
await Promise.all(credentials.map(async (credential) => {
|
|
66
|
+
const owner = credential.ownerId ?? credential.id;
|
|
67
|
+
const snapshot = await this.fetchOne(credential);
|
|
68
|
+
if (snapshot)
|
|
69
|
+
this.last.set(owner, snapshot);
|
|
70
|
+
}));
|
|
71
|
+
// An account that was removed must not keep its old numbers on the screen.
|
|
72
|
+
const owners = new Set(credentials.map((c) => c.ownerId ?? c.id));
|
|
73
|
+
for (const owner of [...this.last.keys()])
|
|
74
|
+
if (!owners.has(owner))
|
|
75
|
+
this.last.delete(owner);
|
|
76
|
+
return Object.fromEntries(this.last);
|
|
77
|
+
}
|
|
78
|
+
async fetchOne(credential) {
|
|
79
|
+
const owner = (credential.ownerId ?? credential.id).slice(0, 8);
|
|
80
|
+
try {
|
|
81
|
+
const res = await (this.opts.fetch ?? fetch)(`https://${this.opts.upstream()}/api/oauth/usage`, {
|
|
82
|
+
method: "GET",
|
|
83
|
+
headers: { ...credential.headers, accept: "application/json" },
|
|
84
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
// The status alone: the body of an auth failure is not worth the risk of echoing.
|
|
88
|
+
this.opts.warn?.(`claude usage ${owner}: HTTP ${res.status}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return claudeUsageFromBody(await res.json());
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
this.opts.warn?.(`claude usage ${owner}: ${e.message}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -24,6 +24,17 @@ const USAGE_TIMEOUT_MS = 10_000;
|
|
|
24
24
|
const MODELS_PATH = "/codex/models";
|
|
25
25
|
const MODELS_TIMEOUT_MS = 15_000;
|
|
26
26
|
const MODELS_CACHE_MS = 60 * 60 * 1000;
|
|
27
|
+
/** One image took about 30 s at low quality (2026-09-29); high quality and references take longer. */
|
|
28
|
+
const IMAGE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
29
|
+
/**
|
|
30
|
+
* The shape, asked for in words. The subscription backend accepts the tool's `size` and `quality`
|
|
31
|
+
* and ignores them — even "999x1" and "bogus" came back 200 as a 1536x1024 image (2026-09-29) —
|
|
32
|
+
* while the prompt steers it: "square composition" gave 1254x1254. So the shape is a prompt hint,
|
|
33
|
+
* and quality is the backend's to choose.
|
|
34
|
+
*/
|
|
35
|
+
export const IMAGE_ASPECTS = ["square", "landscape", "portrait"];
|
|
36
|
+
export const IMAGE_FORMATS = ["png", "jpeg", "webp"];
|
|
37
|
+
export const IMAGE_BACKGROUNDS = ["auto", "transparent", "opaque"];
|
|
27
38
|
function anthropicError(status, type, message) {
|
|
28
39
|
return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
|
|
29
40
|
}
|
|
@@ -394,6 +405,107 @@ export class ChatGptAdapter {
|
|
|
394
405
|
const prose = text.trim();
|
|
395
406
|
return prose ? { hits: selected, text: prose } : { hits: selected };
|
|
396
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Hosted image generation, the same way `searchWeb` runs a hosted search: one Responses turn with
|
|
410
|
+
* the `image_generation` tool required. Wire measured 2026-09-29 against the live backend with a
|
|
411
|
+
* ChatGPT subscription: an `image_generation_call` output item whose `result` is the base64 image,
|
|
412
|
+
* beside the `size`, `quality` and `output_format` it chose and a `revised_prompt`; about 30 s.
|
|
413
|
+
*/
|
|
414
|
+
async generateImage(model, req, signal) {
|
|
415
|
+
const tokens = await this.anyCredential();
|
|
416
|
+
if (tokens instanceof Error)
|
|
417
|
+
throw tokens;
|
|
418
|
+
const id = crypto.randomUUID();
|
|
419
|
+
const tool = {
|
|
420
|
+
type: "image_generation",
|
|
421
|
+
output_format: req.format ?? "png",
|
|
422
|
+
...(req.background ? { background: req.background } : {}),
|
|
423
|
+
};
|
|
424
|
+
const prompt = req.aspect ? `${req.prompt}\n\nCompose it as a ${req.aspect} image.` : req.prompt;
|
|
425
|
+
const content = [{ type: "input_text", text: prompt }];
|
|
426
|
+
for (const image of req.images ?? [])
|
|
427
|
+
content.push({ type: "input_image", image_url: `data:${image.mediaType};base64,${image.data}` });
|
|
428
|
+
const body = {
|
|
429
|
+
model,
|
|
430
|
+
instructions: "Generate the requested image. Use any attached images as references.",
|
|
431
|
+
input: [{ type: "message", role: "user", content }],
|
|
432
|
+
tools: [tool],
|
|
433
|
+
tool_choice: "required",
|
|
434
|
+
reasoning: { effort: "low", summary: "auto" },
|
|
435
|
+
store: false,
|
|
436
|
+
stream: true,
|
|
437
|
+
prompt_cache_key: id,
|
|
438
|
+
client_metadata: { session_id: id, thread_id: id, turn_id: crypto.randomUUID(), "x-codex-window-id": `${id}:0` },
|
|
439
|
+
};
|
|
440
|
+
const timeout = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
|
|
441
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
442
|
+
const res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: {
|
|
445
|
+
"content-type": "application/json",
|
|
446
|
+
accept: "text/event-stream",
|
|
447
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
448
|
+
"chatgpt-account-id": tokens.accountId,
|
|
449
|
+
"OpenAI-Beta": "responses=experimental",
|
|
450
|
+
originator: "codex_cli_rs",
|
|
451
|
+
"session-id": id,
|
|
452
|
+
"thread-id": id,
|
|
453
|
+
"x-client-request-id": id,
|
|
454
|
+
"x-codex-window-id": `${id}:0`,
|
|
455
|
+
},
|
|
456
|
+
body: JSON.stringify(body),
|
|
457
|
+
signal: requestSignal,
|
|
458
|
+
});
|
|
459
|
+
this.noteRateLimits(tokens, rateLimitsFromHeaders(res.headers));
|
|
460
|
+
if (!res.ok || !res.body) {
|
|
461
|
+
const text = await res.text().catch(() => "");
|
|
462
|
+
if (res.status === 401)
|
|
463
|
+
void this.accounts.forceRefresh(tokens.ownerId);
|
|
464
|
+
throw new Error(`ChatGPT image generation: HTTP ${res.status}${text ? ` ${redactErrorText(text, [tokens.accessToken], 200)}` : ""}`);
|
|
465
|
+
}
|
|
466
|
+
const parser = new SseParser();
|
|
467
|
+
const reader = res.body.getReader();
|
|
468
|
+
const decoder = new TextDecoder();
|
|
469
|
+
let image;
|
|
470
|
+
let text = "";
|
|
471
|
+
try {
|
|
472
|
+
for (;;) {
|
|
473
|
+
const { done, value } = await reader.read();
|
|
474
|
+
if (done)
|
|
475
|
+
break;
|
|
476
|
+
for (const event of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
477
|
+
if (event.type === "response.output_text.delta")
|
|
478
|
+
text += String(event.delta ?? "");
|
|
479
|
+
if (event.type === "response.output_item.done") {
|
|
480
|
+
const item = event.item;
|
|
481
|
+
if (item?.type === "image_generation_call" && typeof item.result === "string" && item.result)
|
|
482
|
+
image = item;
|
|
483
|
+
}
|
|
484
|
+
if (event.type === "error" || event.type === "response.failed") {
|
|
485
|
+
const error = (event.error ?? event.response?.error);
|
|
486
|
+
throw new Error(`ChatGPT image generation: ${String(error?.message ?? "backend error")}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
try {
|
|
493
|
+
await reader.cancel();
|
|
494
|
+
}
|
|
495
|
+
catch { /* already closed */ }
|
|
496
|
+
}
|
|
497
|
+
// A refusal comes back as prose and no image; that prose is the only explanation there is.
|
|
498
|
+
if (!image)
|
|
499
|
+
throw new Error(`ChatGPT image generation: no image returned${text.trim() ? `: ${text.trim().slice(0, 300)}` : ""}`);
|
|
500
|
+
const str = (v) => (typeof v === "string" && v ? v : undefined);
|
|
501
|
+
return {
|
|
502
|
+
data: Buffer.from(image.result, "base64"),
|
|
503
|
+
format: str(image.output_format) ?? req.format ?? "png",
|
|
504
|
+
...(str(image.size) ? { size: str(image.size) } : {}),
|
|
505
|
+
...(str(image.quality) ? { quality: str(image.quality) } : {}),
|
|
506
|
+
...(str(image.revised_prompt) ? { revisedPrompt: str(image.revised_prompt) } : {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
397
509
|
/** One in-flight lookup shared by every caller (the status route and the startup refresh). */
|
|
398
510
|
rateLimitsInFlight = null;
|
|
399
511
|
/**
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -35,6 +35,7 @@ import { injectPickerModels, isBootstrapPath } from "./picker.js";
|
|
|
35
35
|
import { ResponseUsageTap } from "./requestlog.js";
|
|
36
36
|
import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
|
|
37
37
|
import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
|
|
38
|
+
import { ClaudeUsage } from "./providers/anthropic-usage.js";
|
|
38
39
|
import { dropForeignThinking } from "./thinking.js";
|
|
39
40
|
const MAX_BODY = 64 * 1024 * 1024;
|
|
40
41
|
const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length"]);
|
|
@@ -177,6 +178,8 @@ export class Proxy {
|
|
|
177
178
|
chatgptAdapters = new Map();
|
|
178
179
|
openaiAdapters = new Map();
|
|
179
180
|
claudeAccounts;
|
|
181
|
+
/** Each Claude subscription account's usage, for the admin status (never a token). */
|
|
182
|
+
claudeUsage;
|
|
180
183
|
deps;
|
|
181
184
|
/**
|
|
182
185
|
* Cooldowns, quarantines and conversation stickiness for provider credentials. In memory: a
|
|
@@ -250,6 +253,20 @@ export class Proxy {
|
|
|
250
253
|
return this.chatgpt(first[0], first[1]);
|
|
251
254
|
return this.chatgpt("codex-login", { type: "chatgpt", auth: "borrow-codex" });
|
|
252
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* One image for `/api/image`, on the first chatgpt provider and the first model it declares. The
|
|
258
|
+
* model only runs the tool call — the image comes from the backend's own image model — so which
|
|
259
|
+
* one it is barely matters.
|
|
260
|
+
*/
|
|
261
|
+
generateImage(req, signal) {
|
|
262
|
+
const first = Object.entries(this.deps.config().providers).find(([, p]) => p.type === "chatgpt");
|
|
263
|
+
if (!first || first[1].type !== "chatgpt")
|
|
264
|
+
return Promise.reject(new Error("image generation needs a ChatGPT provider; add one in the dashboard"));
|
|
265
|
+
const model = first[1].models?.[0]?.id;
|
|
266
|
+
if (!model)
|
|
267
|
+
return Promise.reject(new Error(`provider ${first[0]} declares no models`));
|
|
268
|
+
return this.chatgpt(first[0], first[1]).generateImage(model, req, signal);
|
|
269
|
+
}
|
|
253
270
|
/** Dashboard action: put one resting ChatGPT account back into rotation now. */
|
|
254
271
|
chatgptClearCooldown(ownerId) {
|
|
255
272
|
for (const [name, p] of Object.entries(this.deps.config().providers)) {
|
|
@@ -282,6 +299,10 @@ export class Proxy {
|
|
|
282
299
|
log: deps.log,
|
|
283
300
|
...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
|
|
284
301
|
});
|
|
302
|
+
this.claudeUsage = new ClaudeUsage(this.claudeAccounts, {
|
|
303
|
+
upstream: () => this.deps.config().upstream,
|
|
304
|
+
warn: (message) => deps.log.warn(message),
|
|
305
|
+
});
|
|
285
306
|
this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
|
|
286
307
|
void this.handle(req, res);
|
|
287
308
|
});
|
|
@@ -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
|
+
export const VERSION = "0.7.0";
|