clauderipple 0.8.9 → 0.9.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 +62 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/dist/main.js +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/index.js +1 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/cli/src/tray.js +41 -5
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +195 -53
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/compat.js +10 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/anthropic-account-pool.js +10 -4
- package/dist/router/src/providers/anthropic-accounts.js +26 -1
- package/dist/router/src/providers/chatgpt/catalog.js +45 -14
- package/dist/router/src/providers/chatgpt/index.js +9 -3
- package/dist/router/src/providers/chatgpt/translate.js +9 -5
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +932 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +923 -237
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -408
- package/docs/ARCHITECTURE.md +134 -2
- package/package.json +7 -3
package/dist/router/src/admin.js
CHANGED
|
@@ -23,12 +23,20 @@ import path from "node:path";
|
|
|
23
23
|
import http from "node:http";
|
|
24
24
|
import { execFile, spawn } from "node:child_process";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import { homeDir, searchProviderFor, validate } from "./config.js";
|
|
26
|
+
import { adminPort, homeDir, searchProviderFor, validate } from "./config.js";
|
|
27
|
+
export { adminPort } from "./config.js";
|
|
28
|
+
import { generatedAgents } from "./agents.js";
|
|
29
|
+
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
30
|
+
import { ModSettingsInputError, readModSettings, validateSettings, validateSettingsSchema, writeModSettings } from "./mod-settings.js";
|
|
31
|
+
import { billingOf, claudeChoices, fillLadder, ladderNeedsFill } from "../../mod/hooks/ladder.js";
|
|
32
|
+
import { normalizeSettings } from "../../mod/hooks/settings.js";
|
|
33
|
+
import { chatPickerModels } from "./picker.js";
|
|
27
34
|
import { PRESETS } from "./presets.js";
|
|
28
35
|
import { resolveCompatibleCaps } from "./compat.js";
|
|
29
36
|
import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
|
|
30
37
|
import { measureModel, refusedByPlan } from "./capabilities.js";
|
|
31
38
|
import { catalogEntry } from "./catalog.js";
|
|
39
|
+
import { ModelScoreCatalog, buildModelScores } from "./model-scores.js";
|
|
32
40
|
import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
|
|
33
41
|
import { fetchClaudeModels, newClaudeReleases } from "./providers/anthropic-models.js";
|
|
34
42
|
import { codexEnabled, codexHome } from "../../cli/src/codex.js";
|
|
@@ -39,9 +47,9 @@ import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
|
|
|
39
47
|
import { statusModEnabled, syncModelSlots } from "../../cli/src/settings.js";
|
|
40
48
|
import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
41
49
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
42
|
-
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
50
|
+
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount, setPreferredClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
43
51
|
import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
|
|
44
|
-
import { IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
|
|
52
|
+
import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from "./providers/chatgpt/index.js";
|
|
45
53
|
import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
|
|
46
54
|
import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
|
|
47
55
|
import { GoogleOAuthSession } from "./providers/google/login.js";
|
|
@@ -193,9 +201,6 @@ async function claudeModelList(deps) {
|
|
|
193
201
|
const live = await deps.claudeModels?.().catch(() => null);
|
|
194
202
|
return live?.length ? live.map(({ line: _line, createdAt: _createdAt, ...model }) => ({ ...model, name: model.name ?? model.id })) : CLAUDE_MODEL_FALLBACK;
|
|
195
203
|
}
|
|
196
|
-
export function adminPort(cfg) {
|
|
197
|
-
return cfg.admin?.port ?? cfg.listen.port + 1;
|
|
198
|
-
}
|
|
199
204
|
function settingsPath() {
|
|
200
205
|
return process.env.CLAUDE_SETTINGS_PATH ?? path.join(os.homedir(), ".claude", "settings.json");
|
|
201
206
|
}
|
|
@@ -217,16 +222,6 @@ function readSettingsEnv() {
|
|
|
217
222
|
return {};
|
|
218
223
|
}
|
|
219
224
|
}
|
|
220
|
-
/** Whether ClaudeRipple's agent-title hook is registered in settings.json (see packages/cli/src/settings.ts). */
|
|
221
|
-
function agentTitleHookEnabled() {
|
|
222
|
-
try {
|
|
223
|
-
const s = JSON.parse(fs.readFileSync(settingsPath(), "utf8"));
|
|
224
|
-
return (s.hooks?.PreToolUse ?? []).some((e) => e._clauderipple === "agent-title");
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
225
|
/** Newest mtime of the served GUI files; the page reloads itself when this changes (an open window would otherwise run stale JS). */
|
|
231
226
|
function uiRevision() {
|
|
232
227
|
try {
|
|
@@ -297,8 +292,119 @@ export function googleHealth(accounts) {
|
|
|
297
292
|
const inRotation = accounts.filter((a) => a.state !== "paused" && a.state !== "needs-login");
|
|
298
293
|
return inRotation.length > 0 && inRotation.every((a) => a.state === "needs-verification") ? { needsLogin: false, needsVerification: true } : { needsLogin: false };
|
|
299
294
|
}
|
|
295
|
+
/** Shared by status and mod options: refresh old quota, then read per-account rotation state. */
|
|
296
|
+
async function chatgptStatus(deps, opts = {}) {
|
|
297
|
+
const chatgpt = deps.chatgpt?.() ?? { quota: {}, auth: {} };
|
|
298
|
+
const staleReasons = {};
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
for (const [name, q] of Object.entries(chatgpt.quota)) {
|
|
301
|
+
if (!chatgpt.refresh)
|
|
302
|
+
break;
|
|
303
|
+
const at = q && typeof q.at === "number" ? q.at : 0;
|
|
304
|
+
if (!opts.refresh && at && now - at < 10 * 60 * 1000)
|
|
305
|
+
continue;
|
|
306
|
+
const fresh = await chatgpt.refresh(name);
|
|
307
|
+
if (fresh)
|
|
308
|
+
chatgpt.quota[name] = fresh;
|
|
309
|
+
else
|
|
310
|
+
staleReasons[name] = opts.refresh ? "refresh requested but lookup failed" : "lookup failed; showing last known value";
|
|
311
|
+
}
|
|
312
|
+
const signedIn = {};
|
|
313
|
+
for (const [name, p] of Object.entries(deps.config().providers)) {
|
|
314
|
+
if (p.type === "chatgpt")
|
|
315
|
+
signedIn[name] = chatgptSignedIn(p.auth);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
quota: chatgpt.quota, auth: chatgpt.auth, signedIn, accounts: chatgpt.accounts?.() ?? {},
|
|
319
|
+
...(Object.keys(staleReasons).length ? { stale: true, staleReason: staleReasons } : {}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
323
|
+
const modEfforts = (levels) => (levels ?? []).filter((level) => MOD_EFFORTS.includes(level));
|
|
324
|
+
async function modOptions(deps, scores) {
|
|
325
|
+
const cfg = deps.config();
|
|
326
|
+
const chatgpt = await chatgptStatus(deps);
|
|
327
|
+
const google = deps.google?.().accounts() ?? {};
|
|
328
|
+
const credentials = deps.credentials?.() ?? {};
|
|
329
|
+
const health = deps.providerHealth?.() ?? {};
|
|
330
|
+
const providers = Object.entries(cfg.providers).map(([name, p]) => {
|
|
331
|
+
let usable = true;
|
|
332
|
+
if (p.type === "chatgpt") {
|
|
333
|
+
const accounts = chatgpt.accounts[name];
|
|
334
|
+
usable = accounts ? accounts.some(a => a.state === "ready" && !exhaustedForMs(a.quota))
|
|
335
|
+
: chatgpt.signedIn[name] === true && !exhaustedForMs(chatgpt.quota[name]);
|
|
336
|
+
}
|
|
337
|
+
else if (p.type === "google") {
|
|
338
|
+
usable = p.auth === "antigravity" ? (google[name] ?? []).some(a => a.state === "ready")
|
|
339
|
+
: !!(p.apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
|
|
340
|
+
}
|
|
341
|
+
else if (p.type === "anthropic") {
|
|
342
|
+
usable = p.auth === "api-key" ? !!(p.apiKey || process.env.ANTHROPIC_API_KEY) : claudeAuthStore(deps).describeSource() !== null;
|
|
343
|
+
}
|
|
344
|
+
if (credentials[name])
|
|
345
|
+
usable = usable && credentials[name].some(c => c.state === "ready");
|
|
346
|
+
const exhausted = health[name];
|
|
347
|
+
return { name, type: p.type, usable: usable && !exhausted, billing: billingOf(p),
|
|
348
|
+
...(exhausted ? { reason: exhausted.reason, exhaustedAt: exhausted.at, exhaustedUntil: exhausted.until } : {}), };
|
|
349
|
+
});
|
|
350
|
+
const models = Object.entries(cfg.providers).flatMap(([provider, p]) => (p.models ?? []).map(m => ({ model: m.id, provider, name: m.name ?? m.id, judgeOnly: isJudgeOnlyModel(m.id) })));
|
|
351
|
+
const zen = Object.values(cfg.providers).find(p => {
|
|
352
|
+
if (!p.models?.some(m => isJudgeOnlyModel(m.id)))
|
|
353
|
+
return false;
|
|
354
|
+
if ("preset" in p && p.preset === "opencode-zen")
|
|
355
|
+
return true;
|
|
356
|
+
if (!("url" in p) || !p.url)
|
|
357
|
+
return false;
|
|
358
|
+
const url = new URL(p.url);
|
|
359
|
+
return url.hostname === "opencode.ai" && /^\/zen\/v1(?:\/|$)/.test(url.pathname);
|
|
360
|
+
});
|
|
361
|
+
const model = zen?.models?.find(m => isJudgeOnlyModel(m.id))?.id ?? null;
|
|
362
|
+
const catalog = effortLevels(cfg).providers;
|
|
363
|
+
const agents = generatedAgents(cfg, deps.agentDir, undefined, deps.log).map(a => ({
|
|
364
|
+
...a,
|
|
365
|
+
efforts: modEfforts(catalog[a.provider]?.models?.[a.model] ?? catalog[a.provider]?.default),
|
|
366
|
+
billing: billingOf(cfg.providers[a.provider], a.model),
|
|
367
|
+
}));
|
|
368
|
+
const scored = buildModelScores(cfg, await scores.get());
|
|
369
|
+
const claudeEfforts = modEfforts(catalog.anthropic?.default);
|
|
370
|
+
return { agents, models, providers, zen: { configured: model !== null, model }, scores: scored, claudeEfforts };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* What the four workers can be filled from: the generated workers whose provider can run now, with
|
|
374
|
+
* their ladders, billing and cheapest measured cost, then Claude's own models (always there).
|
|
375
|
+
*/
|
|
376
|
+
function ladderChoices(options) {
|
|
377
|
+
const usable = new Set(options.providers.filter(p => p.usable).map(p => p.name));
|
|
378
|
+
return [
|
|
379
|
+
...options.agents.filter(a => usable.has(a.provider)).map(a => {
|
|
380
|
+
const costs = (options.scores.models.find(m => m.model === a.model && m.provider === a.provider)?.variants ?? []).flatMap(v => typeof v.costPerTask === "number" ? [v.costPerTask] : []);
|
|
381
|
+
return { target: { agent: a.name }, model: a.model, provider: a.provider, billing: a.billing, efforts: a.efforts, ...(costs.length ? { cost: Math.min(...costs) } : {}) };
|
|
382
|
+
}),
|
|
383
|
+
...claudeChoices(options.claudeEfforts),
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Fill empty ordered lists in Base and saved modes from the same ticked, connected choices.
|
|
388
|
+
* Existing lists survive availability and allow-list changes; dispatch handles skips.
|
|
389
|
+
*/
|
|
390
|
+
async function withFilledLadder(deps, scores, settings) {
|
|
391
|
+
const general = settings.rules.general;
|
|
392
|
+
if (![general, ...settings.modes].some(set => ladderNeedsFill(set.rungs)))
|
|
393
|
+
return settings;
|
|
394
|
+
try {
|
|
395
|
+
const choices = ladderChoices(await modOptions(deps, scores));
|
|
396
|
+
const { rungs } = fillLadder(general.rungs, settings.allow, choices);
|
|
397
|
+
const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices).rungs }));
|
|
398
|
+
return { ...settings, rules: { general: { mode: "ladder", rungs } }, modes };
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
deps.log.warn(`admin: filling the worker ladder failed: ${error.message}`);
|
|
402
|
+
return settings;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
300
405
|
async function buildStatus(deps, opts = {}) {
|
|
301
406
|
const cfg = deps.config();
|
|
407
|
+
const health = deps.providerHealth?.() ?? {};
|
|
302
408
|
const providers = {};
|
|
303
409
|
// Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
|
|
304
410
|
// them by whichever TCP check answered first, so the Health list reshuffled on every poll.
|
|
@@ -316,6 +422,8 @@ async function buildStatus(deps, opts = {}) {
|
|
|
316
422
|
url,
|
|
317
423
|
type: p.type,
|
|
318
424
|
reachable,
|
|
425
|
+
usable: reachable && !health[name],
|
|
426
|
+
...(health[name] ? { exhaustion: health[name] } : {}),
|
|
319
427
|
// Whether the router could run a `WebSearch` side request through this provider, so the
|
|
320
428
|
// Clients screen can say so beside a model name. Nothing on that screen said which ones
|
|
321
429
|
// those are, and a `smallFast` slot pointed at one turns every search into a visible failure
|
|
@@ -348,35 +456,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
348
456
|
}));
|
|
349
457
|
for (const [name, entry] of checked)
|
|
350
458
|
providers[name] = entry;
|
|
351
|
-
const chatgpt = deps
|
|
352
|
-
// A snapshot from response headers ages the moment GPT traffic stops: measured 2026-09-20, the
|
|
353
|
-
// status route showed 1% from 11 hours earlier while the account was at 40%. Past ten minutes,
|
|
354
|
-
// ask the backend directly. The lookup shares one in-flight promise per provider, so concurrent
|
|
355
|
-
// status polls (the GUI does one every 5s) collapse into a single request. A failed lookup keeps
|
|
356
|
-
// the last good numbers and says why, rather than blanking the read the budget decision uses.
|
|
357
|
-
const STALE_MS = 10 * 60 * 1000;
|
|
358
|
-
const staleReasons = {};
|
|
359
|
-
const refresh = deps.chatgpt?.().refresh;
|
|
360
|
-
if (refresh) {
|
|
361
|
-
const now = Date.now();
|
|
362
|
-
for (const [name, q] of Object.entries(chatgpt.quota)) {
|
|
363
|
-
const at = q && typeof q.at === "number" ? q.at : 0;
|
|
364
|
-
if (!opts.refresh && at && now - at < STALE_MS)
|
|
365
|
-
continue;
|
|
366
|
-
const fresh = await refresh(name);
|
|
367
|
-
if (fresh)
|
|
368
|
-
chatgpt.quota[name] = fresh;
|
|
369
|
-
else
|
|
370
|
-
staleReasons[name] = opts.refresh ? "refresh requested but lookup failed" : "lookup failed; showing last known value";
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
const stale = Object.keys(staleReasons).length > 0;
|
|
374
|
-
const signedIn = {};
|
|
375
|
-
for (const [name, p] of Object.entries(cfg.providers)) {
|
|
376
|
-
if (p.type !== "chatgpt")
|
|
377
|
-
continue;
|
|
378
|
-
signedIn[name] = chatgptSignedIn(p.auth);
|
|
379
|
-
}
|
|
459
|
+
const chatgpt = await chatgptStatus(deps, opts);
|
|
380
460
|
const env = readSettingsEnv();
|
|
381
461
|
const wantProxy = `http://127.0.0.1:${cfg.listen.port}`;
|
|
382
462
|
const picker = deps.picker?.() ?? { enabled: false, hosts: [], last: null };
|
|
@@ -420,15 +500,14 @@ async function buildStatus(deps, opts = {}) {
|
|
|
420
500
|
cliVersion: cliVersion(),
|
|
421
501
|
// `quota` stays one snapshot per provider (the account in use) for the readers that want one
|
|
422
502
|
// number; `accounts` is the per-account view, read after the refresh above so it is as fresh.
|
|
423
|
-
chatgpt
|
|
503
|
+
chatgpt,
|
|
424
504
|
...(deps.claudeUsage && hasClaudeSubscription(cfg) ? { claude: { accounts: await claudeAccountUsage(deps) } } : {}),
|
|
425
505
|
// Only present for providers that declare a pool; a provider with one credential has nothing
|
|
426
506
|
// to report and would only add a row that never changes.
|
|
427
507
|
credentials: deps.credentials?.() ?? {},
|
|
428
508
|
picker,
|
|
429
|
-
agentTitle: agentTitleHookEnabled(),
|
|
430
509
|
statusMod: statusModEnabled(),
|
|
431
|
-
pickerModels: cfg.
|
|
510
|
+
pickerModels: chatPickerModels(cfg).map((m) => m.name || m.model),
|
|
432
511
|
uiRevision: uiRevision(),
|
|
433
512
|
};
|
|
434
513
|
}
|
|
@@ -581,15 +660,17 @@ export function parseImageRequest(raw) {
|
|
|
581
660
|
}
|
|
582
661
|
return out;
|
|
583
662
|
}
|
|
584
|
-
function readBody(req, limit = MAX_BODY) {
|
|
663
|
+
function readBody(req, limit = MAX_BODY, overflowError) {
|
|
585
664
|
return new Promise((resolveP, reject) => {
|
|
586
665
|
const chunks = [];
|
|
587
666
|
let n = 0;
|
|
588
667
|
req.on("data", (c) => {
|
|
589
668
|
n += c.length;
|
|
590
669
|
if (n > limit) {
|
|
591
|
-
|
|
592
|
-
|
|
670
|
+
chunks.length = 0;
|
|
671
|
+
reject(overflowError ?? new Error("body too large"));
|
|
672
|
+
if (!overflowError)
|
|
673
|
+
req.destroy(); // mod saves must receive their 413, not a reset
|
|
593
674
|
return;
|
|
594
675
|
}
|
|
595
676
|
chunks.push(c);
|
|
@@ -1224,7 +1305,7 @@ async function pickerModels(deps) {
|
|
|
1224
1305
|
const injected = new Set(deps.config().cli.extraModels.map((m) => m.model));
|
|
1225
1306
|
const seen = new Set();
|
|
1226
1307
|
const models = entries.filter((e) => {
|
|
1227
|
-
if (injected.has(e.id) || seen.has(e.id))
|
|
1308
|
+
if (injected.has(e.id) || seen.has(e.id) || isJudgeOnlyModel(e.id))
|
|
1228
1309
|
return false;
|
|
1229
1310
|
seen.add(e.id);
|
|
1230
1311
|
return true;
|
|
@@ -1246,7 +1327,9 @@ function tailLines(file, n) {
|
|
|
1246
1327
|
}
|
|
1247
1328
|
function safeStaticPath(urlPath) {
|
|
1248
1329
|
const decoded = decodeURIComponent(urlPath.split("?")[0] ?? "/");
|
|
1249
|
-
|
|
1330
|
+
// Clients that ask for /favicon.ico without reading the page's <link rel="icon"> get the PNG;
|
|
1331
|
+
// browsers sniff the bytes, so a PNG served for .ico shows.
|
|
1332
|
+
const rel = decoded === "/" ? "index.html" : decoded === "/favicon.ico" ? "favicon-32.png" : decoded.replace(/^\/+/, "");
|
|
1250
1333
|
const full = path.resolve(UI_ROOT, rel);
|
|
1251
1334
|
const rootWithSep = UI_ROOT.endsWith(path.sep) ? UI_ROOT : UI_ROOT + path.sep;
|
|
1252
1335
|
if (full !== UI_ROOT && !full.startsWith(rootWithSep))
|
|
@@ -1290,6 +1373,7 @@ function serveStatic(urlPath, res) {
|
|
|
1290
1373
|
});
|
|
1291
1374
|
}
|
|
1292
1375
|
export function startAdmin(deps) {
|
|
1376
|
+
const scores = new ModelScoreCatalog({ home: homeDir(), warn: message => deps.log.warn(message), ...(deps.modelScoresFetch ? { fetch: deps.modelScoresFetch } : {}) });
|
|
1293
1377
|
// Set again once the socket is bound (port 0 in tests); the Origin check below compares against it.
|
|
1294
1378
|
let boundPort = adminPort(deps.config());
|
|
1295
1379
|
const server = http.createServer((req, res) => {
|
|
@@ -1349,6 +1433,36 @@ export function startAdmin(deps) {
|
|
|
1349
1433
|
sendJson(res, 200, await buildStatus(deps, { refresh }));
|
|
1350
1434
|
return;
|
|
1351
1435
|
}
|
|
1436
|
+
if (pathname === "/api/mod-settings" && method === "GET") {
|
|
1437
|
+
const snapshot = readModSettings();
|
|
1438
|
+
// Filled here as well as on save, so a ladder with empty rungs (Auto migrated, or never set) reads whole to every surface.
|
|
1439
|
+
sendJson(res, 200, { ...snapshot, settings: await withFilledLadder(deps, scores, snapshot.settings) });
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (pathname === "/api/mod-settings" && method === "PUT") {
|
|
1443
|
+
const bytes = await readBody(req, MAX_BODY, new ModSettingsInputError("body too large", 413));
|
|
1444
|
+
let parsed;
|
|
1445
|
+
try {
|
|
1446
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
throw new ModSettingsInputError("invalid JSON");
|
|
1450
|
+
}
|
|
1451
|
+
validateSettingsSchema(parsed);
|
|
1452
|
+
const body = parsed;
|
|
1453
|
+
if (body && typeof body === "object" && body.settings && typeof body.settings === "object" && !Array.isArray(body.settings)) {
|
|
1454
|
+
// Checked before normalizing, which would drop a credential field the check is there to refuse.
|
|
1455
|
+
validateSettings(body.settings);
|
|
1456
|
+
body.settings = await withFilledLadder(deps, scores, normalizeSettings(body.settings));
|
|
1457
|
+
}
|
|
1458
|
+
const result = writeModSettings(parsed);
|
|
1459
|
+
sendJson(res, result.stale ? 409 : 200, result.current);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (pathname === "/api/mod-options" && method === "GET") {
|
|
1463
|
+
sendJson(res, 200, await modOptions(deps, scores));
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1352
1466
|
if (pathname === "/api/presets" && method === "GET") {
|
|
1353
1467
|
sendJson(res, 200, { presets: PRESETS });
|
|
1354
1468
|
return;
|
|
@@ -1643,6 +1757,30 @@ export function startAdmin(deps) {
|
|
|
1643
1757
|
});
|
|
1644
1758
|
return;
|
|
1645
1759
|
}
|
|
1760
|
+
// Which account a new conversation tries first: a stored account id, or null for the current login.
|
|
1761
|
+
if (pathname === "/api/claude-accounts" && method === "PUT") {
|
|
1762
|
+
let preferred;
|
|
1763
|
+
try {
|
|
1764
|
+
preferred = JSON.parse((await readBody(req)).toString("utf8")).preferred;
|
|
1765
|
+
}
|
|
1766
|
+
catch {
|
|
1767
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (preferred === "current")
|
|
1771
|
+
preferred = null;
|
|
1772
|
+
if (preferred !== null && (typeof preferred !== "string" || !preferred)) {
|
|
1773
|
+
sendJson(res, 400, { error: "expected {preferred: account id or null}" });
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (!setPreferredClaudeAccount(homeDir(), preferred)) {
|
|
1777
|
+
sendJson(res, 404, { error: "Claude account not found" });
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
deps.log.info(`admin: Claude accounts now try ${preferred === null ? "the current login" : `account ${preferred.slice(0, 8)}`} first`);
|
|
1781
|
+
sendJson(res, 200, { ok: true });
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1646
1784
|
if (pathname.startsWith("/api/claude-accounts/") && (method === "PATCH" || method === "DELETE")) {
|
|
1647
1785
|
let id;
|
|
1648
1786
|
try {
|
|
@@ -2046,6 +2184,10 @@ export function startAdmin(deps) {
|
|
|
2046
2184
|
sendJson(res, 404, { error: "not found" });
|
|
2047
2185
|
}
|
|
2048
2186
|
catch (e) {
|
|
2187
|
+
if (e instanceof ModSettingsInputError && !res.headersSent) {
|
|
2188
|
+
sendJson(res, e.status, { error: e.message });
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2049
2191
|
deps.log.error(`admin error: ${e.stack ?? e}`);
|
|
2050
2192
|
if (!res.headersSent)
|
|
2051
2193
|
sendJson(res, 500, { error: e.message });
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { homeDir } from "./config.js";
|
|
15
|
+
import { adminPort, homeDir } from "./config.js";
|
|
16
16
|
import { declaredBy } from "./routing.js";
|
|
17
|
-
import {
|
|
17
|
+
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
18
18
|
import { shortName } from "../../cli/src/hooks/agent-title.js";
|
|
19
19
|
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
20
20
|
export function defaultAgentDir() {
|
|
@@ -33,8 +33,8 @@ const BODY = [
|
|
|
33
33
|
"너는 이 세션의 실행자다. 위임받은 작업을 직접 끝내고 직접 검증해서 결론만 간결히 보고한다.",
|
|
34
34
|
"파일 전문·코드 덤프는 보고에 넣지 않는다. 추측은 추측이라 명시하고 근거는 파일:줄번호로 댄다.",
|
|
35
35
|
"다른 에이전트에게 넘기지 않는다. 프롬프트 첫 줄의 `[[ripple: …]]` 표식은 라우팅용이니 무시한다.",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
36
|
+
"**시키지 않은 파일은 만들지 않는다.** 작업 지시에 결과물 경로가 없으면 조사·감사라도 작업 디렉터리(cwd)에 아무 파일도 남기지 않는다.",
|
|
37
|
+
"작업 지시가 결과물 경로(파일·폴더)를 정했으면 조사·감사·대조 작업이라도 그 경로에는 반드시 만든다. 그 밖의 메모·사본·임시 파일은 `/tmp` 아래에만 만들고, 끝나면 지운다.",
|
|
38
38
|
].join("\n");
|
|
39
39
|
/**
|
|
40
40
|
* Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
|
|
@@ -134,7 +134,7 @@ function scanDir(dir, log) {
|
|
|
134
134
|
export function agentAliases(dir, log) {
|
|
135
135
|
const out = {};
|
|
136
136
|
for (const entry of scanDir(dir, log).values()) {
|
|
137
|
-
if (!entry.model)
|
|
137
|
+
if (!entry.model || isJudgeOnlyModel(entry.model))
|
|
138
138
|
continue;
|
|
139
139
|
out[entry.name] = entry.model.replace(/@[a-z]+$/i, "");
|
|
140
140
|
}
|
|
@@ -190,6 +190,23 @@ function readManifest(file) {
|
|
|
190
190
|
}
|
|
191
191
|
return { agents: [] };
|
|
192
192
|
}
|
|
193
|
+
/** Owned files that still exist and name a configured worker, not hand-written agents. */
|
|
194
|
+
export function generatedAgents(cfg, dir = defaultAgentDir(), manifestPath = path.join(homeDir(), "generated-agents.json"), log) {
|
|
195
|
+
const owned = new Set(readManifest(manifestPath).agents);
|
|
196
|
+
const result = [];
|
|
197
|
+
for (const entry of scanDir(dir, log).values()) {
|
|
198
|
+
if (!owned.has(entry.name) || !entry.model || isJudgeOnlyModel(entry.model))
|
|
199
|
+
continue;
|
|
200
|
+
const model = entry.model.replace(/@[a-z]+$/i, "");
|
|
201
|
+
const owners = declaredBy(model, cfg);
|
|
202
|
+
const provider = owners.length === 1 ? owners[0] : undefined;
|
|
203
|
+
const target = provider ? cfg.providers[provider] : undefined;
|
|
204
|
+
if (!provider || !target || (target.type === "anthropic" && !target.accountPool))
|
|
205
|
+
continue;
|
|
206
|
+
result.push({ name: entry.name, model, provider });
|
|
207
|
+
}
|
|
208
|
+
return result.sort((a, b) => a.name.localeCompare(b.name));
|
|
209
|
+
}
|
|
193
210
|
function writeManifest(file, manifest, log) {
|
|
194
211
|
try {
|
|
195
212
|
fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
|
|
@@ -224,6 +241,8 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
224
241
|
if (provider.type === "anthropic" && !provider.accountPool)
|
|
225
242
|
continue;
|
|
226
243
|
for (const model of provider.models ?? []) {
|
|
244
|
+
if (isJudgeOnlyModel(model.id))
|
|
245
|
+
continue;
|
|
227
246
|
const owners = declaredBy(model.id, cfg);
|
|
228
247
|
if (owners.length !== 1) {
|
|
229
248
|
if (owners.length > 1)
|
|
@@ -6,6 +6,8 @@ import fs from "node:fs";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { agentAliases } from "./agents.js";
|
|
9
|
+
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
10
|
+
import { chatPickerModels } from "./picker.js";
|
|
9
11
|
export const BOOTSTRAP_PATH = "/api/claude_cli/bootstrap";
|
|
10
12
|
/**
|
|
11
13
|
* Model ids named in the user's agent definitions (~/.claude/agents/*.md frontmatter `model:`),
|
|
@@ -27,7 +29,7 @@ export function agentModelIds(dirs = [path.join(os.homedir(), ".claude", "agents
|
|
|
27
29
|
try {
|
|
28
30
|
const head = fs.readFileSync(path.join(dir, f), "utf8").slice(0, 8192);
|
|
29
31
|
const m = /^model:\s*['"]?([A-Za-z0-9._\/:\-]+(?:@[a-z]+)?)/m.exec(head);
|
|
30
|
-
if (m)
|
|
32
|
+
if (m && !isJudgeOnlyModel(m[1]))
|
|
31
33
|
out.add(m[1]);
|
|
32
34
|
}
|
|
33
35
|
catch {
|
|
@@ -60,7 +62,7 @@ export function injectBootstrap(body, cfg, agentDirs) {
|
|
|
60
62
|
// An "<model>@<effort>" entry is the same model with a different effort, so it has the same window.
|
|
61
63
|
return { model: id, name: `${named?.name ?? base}${effort ? ` · ${effort}` : ""}`, ...(named?.contextWindow ? { contextWindow: named.contextWindow } : {}) };
|
|
62
64
|
});
|
|
63
|
-
const extra = [...cfg.cli.extraModels, ...fromAgents];
|
|
65
|
+
const extra = chatPickerModels({ ...cfg, aliases, cli: { ...cfg.cli, extraModels: [...cfg.cli.extraModels, ...fromAgents] } });
|
|
64
66
|
const win = cfg.cli.autoCompactWindow;
|
|
65
67
|
// Routed models do not share a context window; the global value is only the fallback for entries
|
|
66
68
|
// that do not name their own, so a per-entry window alone is reason enough to write the map.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Sanitise Claude Code's Anthropic-only request extensions for providers that expose
|
|
2
2
|
// an Anthropic-shaped Messages endpoint without implementing Anthropic's full feature set.
|
|
3
3
|
// Pure: callers receive a new object and an audit list; the input is never mutated.
|
|
4
|
-
import { droppedServerToolNote } from "./providers/chatgpt/translate.js";
|
|
4
|
+
import { droppedServerToolNote, scrubSchema } from "./providers/chatgpt/translate.js";
|
|
5
5
|
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
6
6
|
export const STRICT_COMPAT_CAPS = {
|
|
7
7
|
effortLevels: [],
|
|
@@ -169,6 +169,7 @@ export function sanitizeForCompatible(json, caps) {
|
|
|
169
169
|
const droppedNames = new Set();
|
|
170
170
|
let deferred = 0;
|
|
171
171
|
let dropped = 0;
|
|
172
|
+
const patterns = { count: 0 };
|
|
172
173
|
for (const tool of out.tools) {
|
|
173
174
|
const source = record(tool);
|
|
174
175
|
if (!source) {
|
|
@@ -188,13 +189,20 @@ export function sanitizeForCompatible(json, caps) {
|
|
|
188
189
|
delete next.defer_loading;
|
|
189
190
|
deferred++;
|
|
190
191
|
}
|
|
192
|
+
// A strict regex engine refuses the whole request over one pattern it cannot compile:
|
|
193
|
+
// DeepSeek's Anthropic endpoint answers 400 `"^[^\\0]*$" is not a "regex"` for the Artifact
|
|
194
|
+
// tool (issue #51). The client validates its own tool input, so the pattern adds nothing here.
|
|
195
|
+
if (next.input_schema !== undefined)
|
|
196
|
+
next.input_schema = scrubSchema(next.input_schema, patterns);
|
|
191
197
|
kept.push(next);
|
|
192
198
|
}
|
|
193
199
|
if (deferred > 0)
|
|
194
200
|
changes.push(`defer_loading×${deferred}`);
|
|
195
201
|
if (dropped > 0)
|
|
196
202
|
changes.push(`server_tools×${dropped}`);
|
|
197
|
-
if (
|
|
203
|
+
if (patterns.count > 0)
|
|
204
|
+
changes.push(`pattern×${patterns.count}`);
|
|
205
|
+
if (deferred > 0 || dropped > 0 || patterns.count > 0)
|
|
198
206
|
out.tools = kept;
|
|
199
207
|
const toolChoice = record(out.tool_choice);
|
|
200
208
|
if (toolChoice && typeof toolChoice.name === "string" && droppedNames.has(toolChoice.name)) {
|
|
@@ -117,6 +117,9 @@ export const DEFAULTS = {
|
|
|
117
117
|
health: { maxConsecutiveUpstreamFailures: 20 },
|
|
118
118
|
log: { maxBytes: 5 * 1024 * 1024, keep: 3 },
|
|
119
119
|
};
|
|
120
|
+
export function adminPort(cfg) {
|
|
121
|
+
return cfg.admin?.port ?? cfg.listen.port + 1;
|
|
122
|
+
}
|
|
120
123
|
export function homeDir() {
|
|
121
124
|
return process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple");
|
|
122
125
|
}
|
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
// stays bad (observed: 10h of ENOTFOUND, 11 remote-control workers lost). After N
|
|
3
3
|
// consecutive upstream *connect-level* failures with no success in between, exit
|
|
4
4
|
// with a distinctive code so the supervisor restarts a fresh process.
|
|
5
|
+
/** Only definite billing/quota exhaustion, never a bare rate limit or RESOURCE_EXHAUSTED.
|
|
6
|
+
* 402 is already `exhausted` in pool.ts; Codex's usage_limit_reached is named in
|
|
7
|
+
* providers/chatgpt/translate.ts. Other vendors share the explicit billing vocabulary.
|
|
8
|
+
* Reasons are codes, not upstream prose (which can contain credentials).
|
|
9
|
+
*/
|
|
10
|
+
export function providerExhaustion(status, body = "", retryMs, now = Date.now()) {
|
|
11
|
+
if (status !== 402 && status !== 429)
|
|
12
|
+
return undefined;
|
|
13
|
+
const balance = /\b(?:insufficient[_ -]+(?:balance|credits?|funds)|(?:balance|credits?)[_ -]+(?:insufficient|exhausted|depleted))\b/i.test(body);
|
|
14
|
+
const billing = /\bbilling[_ -]+(?:hard[_ -]+)?(?:limit|exhausted|quota)|\b(?:exhausted|exceeded|insufficient)[_ -]+billing\b/i.test(body);
|
|
15
|
+
const quota = /\b(?:insufficient_quota|usage_limit_reached|quota[_ -]+exhausted|exhausted[_ -]+quota)\b/i.test(body);
|
|
16
|
+
if (status !== 402 && !balance && !billing && !quota)
|
|
17
|
+
return undefined;
|
|
18
|
+
const reason = balance ? "insufficient_balance" : billing || status === 402 ? "billing_exhausted" : "quota_exhausted";
|
|
19
|
+
const wait = retryMs !== undefined && Number.isFinite(retryMs) && retryMs > 0 ? retryMs : 30 * 60_000;
|
|
20
|
+
return { reason, at: now, until: now + wait };
|
|
21
|
+
}
|
|
22
|
+
/** Per router instance, advisory only: manual traffic can prove a topped-up provider healthy. */
|
|
23
|
+
export class ProviderHealth {
|
|
24
|
+
exhausted = new Map();
|
|
25
|
+
observe(provider, status, exhaustion) {
|
|
26
|
+
if (exhaustion)
|
|
27
|
+
this.exhausted.set(provider, exhaustion);
|
|
28
|
+
else if (status >= 200 && status < 300)
|
|
29
|
+
this.exhausted.delete(provider);
|
|
30
|
+
}
|
|
31
|
+
snapshot(now = Date.now()) {
|
|
32
|
+
for (const [name, value] of this.exhausted)
|
|
33
|
+
if (value.until <= now)
|
|
34
|
+
this.exhausted.delete(name);
|
|
35
|
+
return Object.fromEntries(this.exhausted);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
5
38
|
export const EXIT_UPSTREAM_UNREACHABLE = 75;
|
|
6
39
|
const CONNECT_ERRORS = new Set([
|
|
7
40
|
"ENOTFOUND",
|
package/dist/router/src/index.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// `GET /v1/models` and by the Codex model catalog that puts these names in the Codex app picker.
|
|
4
4
|
import { effortLevels } from "../admin.js";
|
|
5
5
|
import { claudeSupportsEffort } from "./translate.js";
|
|
6
|
+
import { isJudgeOnlyModel } from "../model-policy.js";
|
|
6
7
|
export function ingressModels(cfg) {
|
|
7
8
|
const levels = effortLevels(cfg).providers;
|
|
8
9
|
const out = new Map();
|
|
9
10
|
const add = (id, provider, name, explicit) => {
|
|
10
|
-
if (out.has(id))
|
|
11
|
+
if (out.has(id) || isJudgeOnlyModel(id))
|
|
11
12
|
return;
|
|
12
13
|
const p = cfg.providers[provider];
|
|
13
14
|
if (!p || (p.type !== "anthropic" && p.type !== "anthropic-compatible"))
|
|
@@ -22,6 +23,8 @@ export function ingressModels(cfg) {
|
|
|
22
23
|
add(model.id, name, model.name, model.effortLevels);
|
|
23
24
|
}
|
|
24
25
|
for (const [id, route] of Object.entries(cfg.routes)) {
|
|
26
|
+
if (isJudgeOnlyModel(route.model))
|
|
27
|
+
continue;
|
|
25
28
|
const target = cfg.providers[route.provider]?.models?.find((m) => m.id === route.model);
|
|
26
29
|
add(id, route.provider, target?.name, target?.effortLevels);
|
|
27
30
|
}
|