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.
@@ -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
+ }
@@ -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
- function readBody(req) {
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 > MAX_BODY) {
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
- return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n---\n${BODY}\n`;
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;
@@ -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
  /**
@@ -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.5.1";
8
+ export const VERSION = "0.7.0";