clauderipple 0.8.10 → 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 +36 -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/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/settings.js +5 -2
- 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 +170 -52
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -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/chatgpt/index.js +6 -1
- package/dist/router/src/providers/chatgpt/translate.js +1 -1
- 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 +908 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +919 -233
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -410
- package/docs/ARCHITECTURE.md +128 -0
- 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";
|
|
@@ -41,7 +49,7 @@ import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
|
41
49
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
42
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;
|
|
@@ -2070,6 +2184,10 @@ export function startAdmin(deps) {
|
|
|
2070
2184
|
sendJson(res, 404, { error: "not found" });
|
|
2071
2185
|
}
|
|
2072
2186
|
catch (e) {
|
|
2187
|
+
if (e instanceof ModSettingsInputError && !res.headersSent) {
|
|
2188
|
+
sendJson(res, e.status, { error: e.message });
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2073
2191
|
deps.log.error(`admin error: ${e.stack ?? e}`);
|
|
2074
2192
|
if (!res.headersSent)
|
|
2075
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.
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Persistence and normalization have one source each: the router's locked store
|
|
2
|
+
// and the mod's pure schema (no claude-code runtime dependency).
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { homeDir } from "./config.js";
|
|
6
|
+
import { withFileLock, writeJsonAtomic } from "./locked-file.js";
|
|
7
|
+
import { MOD_SETTINGS_SCHEMA_VERSION, hadRetiredRouting, normalizeSettings } from "../../mod/hooks/settings.js";
|
|
8
|
+
export const MAX_MOD_SETTINGS_BYTES = 64 * 1024;
|
|
9
|
+
export class ModSettingsInputError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status = 400) { super(message); this.status = status; }
|
|
12
|
+
}
|
|
13
|
+
function object(value) {
|
|
14
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function revision(value) {
|
|
17
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A field that holds a credential: its last word is key, token, secret, password or the like
|
|
21
|
+
* (`apiKey`, `access_token`, `typesafeKey`). The last word decides, so a count such as
|
|
22
|
+
* `keepWarmMinTokens` — a setting the mod has — is not mistaken for one.
|
|
23
|
+
*/
|
|
24
|
+
function isSecretName(key) {
|
|
25
|
+
const last = key.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])/).filter(Boolean).at(-1)?.toLowerCase() ?? "";
|
|
26
|
+
return ["key", "apikey", "token", "secret", "password", "passwd", "authorization", "credential", "credentials", "bearer"].includes(last);
|
|
27
|
+
}
|
|
28
|
+
/** Iterative traversal also checks objects nested in arrays, without a recursion limit. */
|
|
29
|
+
function hasSecretKey(settings) {
|
|
30
|
+
const pending = [settings];
|
|
31
|
+
while (pending.length) {
|
|
32
|
+
const value = pending.pop();
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
pending.push(...value);
|
|
35
|
+
else if (object(value)) {
|
|
36
|
+
for (const [key, child] of Object.entries(value)) {
|
|
37
|
+
if (isSecretName(key))
|
|
38
|
+
return true;
|
|
39
|
+
pending.push(child);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
export function validateSettings(settings) {
|
|
46
|
+
if (!object(settings))
|
|
47
|
+
throw new ModSettingsInputError("settings must be a JSON object");
|
|
48
|
+
if (Buffer.byteLength(JSON.stringify(settings), "utf8") > MAX_MOD_SETTINGS_BYTES)
|
|
49
|
+
throw new ModSettingsInputError("settings exceeds 64 KiB", 413);
|
|
50
|
+
if (hasSecretKey(settings))
|
|
51
|
+
throw new ModSettingsInputError("settings must not contain credential fields (key, token, secret, password)");
|
|
52
|
+
}
|
|
53
|
+
export function readModSettings(home = homeDir()) {
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = fs.readFileSync(path.join(home, "mod-settings.json"), "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === "ENOENT")
|
|
60
|
+
return { settings: normalizeSettings(null), revision: 0, stored: false };
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
// Corruption is not an empty store: fail rather than silently overwrite it.
|
|
64
|
+
const stored = JSON.parse(text);
|
|
65
|
+
if (!object(stored) || !revision(stored.revision) || stored.revision < 1)
|
|
66
|
+
throw new Error("invalid mod settings store");
|
|
67
|
+
validateSettings(stored.settings);
|
|
68
|
+
// Auto and field rules are gone: the dashboard says so once, until the next save drops them.
|
|
69
|
+
return { settings: normalizeSettings(stored.settings), revision: stored.revision, stored: true, ...(hadRetiredRouting(stored.settings) ? { legacy: true } : {}) };
|
|
70
|
+
}
|
|
71
|
+
/** Reject old writers before normalization/fill can erase evidence of their format. */
|
|
72
|
+
export function validateSettingsSchema(body) {
|
|
73
|
+
if (!object(body))
|
|
74
|
+
throw new ModSettingsInputError("body must be a JSON object");
|
|
75
|
+
if (body.schemaVersion !== MOD_SETTINGS_SCHEMA_VERSION)
|
|
76
|
+
throw new ModSettingsInputError("settings schema unsupported; reload ClaudeRipple before saving", 426);
|
|
77
|
+
}
|
|
78
|
+
/** Exact previous bytes, not the normalized read view; backup failure aborts the write. */
|
|
79
|
+
function backupModSettings(file, previousRevision) {
|
|
80
|
+
const backup = `${file}.bak-${previousRevision}`;
|
|
81
|
+
fs.copyFileSync(file, backup);
|
|
82
|
+
fs.chmodSync(backup, 0o600);
|
|
83
|
+
const fd = fs.openSync(backup, "r");
|
|
84
|
+
try {
|
|
85
|
+
fs.fsyncSync(fd);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
const backups = fs.readdirSync(path.dirname(file)).filter(name => /^mod-settings\.json\.bak-\d+$/.test(name))
|
|
91
|
+
.sort((a, b) => Number(b.split("-").at(-1)) - Number(a.split("-").at(-1)));
|
|
92
|
+
for (const name of backups.slice(10))
|
|
93
|
+
fs.unlinkSync(path.join(path.dirname(file), name));
|
|
94
|
+
}
|
|
95
|
+
/** Compare-and-swap and revision increment share the account stores' cross-process lock. */
|
|
96
|
+
export function writeModSettings(body, home = homeDir()) {
|
|
97
|
+
if (!object(body))
|
|
98
|
+
throw new ModSettingsInputError("body must be a JSON object");
|
|
99
|
+
validateSettingsSchema(body);
|
|
100
|
+
validateSettings(body.settings);
|
|
101
|
+
if (body.revision !== undefined && !revision(body.revision))
|
|
102
|
+
throw new ModSettingsInputError("revision must be a non-negative safe integer");
|
|
103
|
+
const settings = normalizeSettings(body.settings);
|
|
104
|
+
validateSettings(settings);
|
|
105
|
+
const expected = body.revision;
|
|
106
|
+
const file = path.join(home, "mod-settings.json");
|
|
107
|
+
return withFileLock(file, () => {
|
|
108
|
+
const current = readModSettings(home);
|
|
109
|
+
if (expected !== undefined && expected !== current.revision)
|
|
110
|
+
return { stale: true, current };
|
|
111
|
+
if (current.revision === Number.MAX_SAFE_INTEGER)
|
|
112
|
+
throw new Error("mod settings revision exhausted");
|
|
113
|
+
const next = { settings, revision: current.revision + 1, stored: true };
|
|
114
|
+
if (current.stored)
|
|
115
|
+
backupModSettings(file, current.revision);
|
|
116
|
+
writeJsonAtomic(file, next);
|
|
117
|
+
return { stale: false, current: next };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// OpenCode Zen's presets.ts note documents jev's /systemone-only judging shape:
|
|
2
|
+
// it cannot answer an ordinary chat/Responses/Messages turn. Keep it configurable
|
|
3
|
+
// for judges, but never advertise it as a chat model or generate a worker for it.
|
|
4
|
+
export function isJudgeOnlyModel(modelId) {
|
|
5
|
+
return modelId.toLowerCase().startsWith("jev");
|
|
6
|
+
}
|