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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// AA benchmark scores are metadata only: this module never changes routing or calls providers.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { homeDir } from "./config.js";
|
|
5
|
+
import { declaredBy, resolve } from "./routing.js";
|
|
6
|
+
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
7
|
+
export const AA_DATA_URL = "https://raw.githubusercontent.com/PBJ-2/clauderipple/data/aa-models.json";
|
|
8
|
+
const ATTRIBUTION = "Data: Artificial Analysis (artificialanalysis.ai)";
|
|
9
|
+
const TTL_MS = 24 * 60 * 60_000;
|
|
10
|
+
const RETRY_MS = 60 * 60_000;
|
|
11
|
+
/** Namespace, @effort and Claude snapshot date are transport spelling, not AA base identity. */
|
|
12
|
+
export function normalizeAaBase(model) {
|
|
13
|
+
return model.toLowerCase().split("/").at(-1).replace(/@[a-z]+$/, "").replace(/-\d{8}$/, "").replace(/[._]/g, "-");
|
|
14
|
+
}
|
|
15
|
+
/** AA minimal/non-reasoning have no equivalent in the picker's five-level ladder. Do not mislabel. */
|
|
16
|
+
export function mapAaEffort(effort) {
|
|
17
|
+
return effort === "minimal" || effort === "non-reasoning" ? null : effort;
|
|
18
|
+
}
|
|
19
|
+
/** Only the fields consumed here are validated; full dataset schema belongs to the producer. */
|
|
20
|
+
export function parseScoreDataset(raw) {
|
|
21
|
+
if (!raw || typeof raw !== "object")
|
|
22
|
+
throw new Error("invalid AA dataset");
|
|
23
|
+
const d = raw;
|
|
24
|
+
if (d.source !== "https://artificialanalysis.ai" || d.attribution !== ATTRIBUTION || typeof d.generatedAt !== "string" || !Number.isFinite(Date.parse(d.generatedAt)) || !Array.isArray(d.models))
|
|
25
|
+
throw new Error("invalid AA dataset envelope");
|
|
26
|
+
const models = [];
|
|
27
|
+
const slugs = new Set();
|
|
28
|
+
for (const value of d.models) {
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
throw new Error("invalid AA model");
|
|
31
|
+
const m = value;
|
|
32
|
+
const validNumber = (n) => n === null || (typeof n === "number" && Number.isFinite(n));
|
|
33
|
+
if (typeof m.slug !== "string" || !m.slug || slugs.has(m.slug) || typeof m.base !== "string" || !m.base || ![null, "low", "medium", "high", "xhigh", "max", "minimal", "non-reasoning"].includes(m.effort) || !validNumber(m.intelligence) || !validNumber(m.costPerTask))
|
|
34
|
+
throw new Error("invalid AA model score");
|
|
35
|
+
slugs.add(m.slug);
|
|
36
|
+
models.push({ slug: m.slug, base: m.base, effort: m.effort, intelligence: m.intelligence, costPerTask: m.costPerTask });
|
|
37
|
+
}
|
|
38
|
+
return { attribution: ATTRIBUTION, generatedAt: d.generatedAt, models };
|
|
39
|
+
}
|
|
40
|
+
/** Fetch at most daily, single-flight; offline keeps the last good disk/memory copy and retries hourly. */
|
|
41
|
+
export class ModelScoreCatalog {
|
|
42
|
+
dataset = null;
|
|
43
|
+
until = 0;
|
|
44
|
+
loaded = false;
|
|
45
|
+
pending = null;
|
|
46
|
+
options;
|
|
47
|
+
constructor(options = {}) { this.options = options; }
|
|
48
|
+
async get() {
|
|
49
|
+
if (this.pending)
|
|
50
|
+
return this.pending;
|
|
51
|
+
if (this.loaded && this.until > this.now())
|
|
52
|
+
return this.dataset;
|
|
53
|
+
this.pending = this.refresh();
|
|
54
|
+
try {
|
|
55
|
+
return await this.pending;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
this.pending = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
now() { return (this.options.now ?? Date.now)(); }
|
|
62
|
+
warn(message) { (this.options.warn ?? console.warn)(`AA model scores: ${message}`); }
|
|
63
|
+
async refresh() {
|
|
64
|
+
const file = path.join(this.options.home ?? homeDir(), "aa-models.json");
|
|
65
|
+
if (!this.loaded) {
|
|
66
|
+
this.loaded = true;
|
|
67
|
+
try {
|
|
68
|
+
this.dataset = parseScoreDataset(JSON.parse(await fs.readFile(file, "utf8")));
|
|
69
|
+
// Disk mtime records retrieval, not AA's measurement/publication time. Honour TTL on restart.
|
|
70
|
+
const fetched = (await fs.stat(file)).mtimeMs;
|
|
71
|
+
if (fetched <= this.now())
|
|
72
|
+
this.until = fetched + TTL_MS;
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (!(e instanceof Error && "code" in e && e.code === "ENOENT"))
|
|
76
|
+
this.warn(`cache unreadable: ${String(e)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (this.until > this.now())
|
|
80
|
+
return this.dataset;
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
const response = await (this.options.fetch ?? fetch)(AA_DATA_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5_000) });
|
|
84
|
+
if (!response.ok)
|
|
85
|
+
throw new Error(`HTTP ${response.status}`);
|
|
86
|
+
raw = await response.json();
|
|
87
|
+
this.dataset = parseScoreDataset(raw);
|
|
88
|
+
this.until = this.now() + TTL_MS;
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
this.until = this.now() + RETRY_MS;
|
|
92
|
+
this.warn(`refresh failed; using ${this.dataset ? "cached scores" : "unmatched models"}: ${String(e)}`);
|
|
93
|
+
return this.dataset;
|
|
94
|
+
}
|
|
95
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
96
|
+
try {
|
|
97
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
98
|
+
await fs.writeFile(tmp, JSON.stringify(raw, null, 2) + "\n");
|
|
99
|
+
await fs.rename(tmp, file);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
this.warn(`could not persist cache: ${String(e)}`);
|
|
103
|
+
await fs.rm(tmp, { force: true }).catch(error => this.warn(`could not remove temporary cache: ${String(error)}`));
|
|
104
|
+
}
|
|
105
|
+
return this.dataset;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Enumerate finite configured ids, not hypothetical ids a prefix could accept. Use resolve() so
|
|
110
|
+
* explicit routes/direct precedence and ambiguous owners behave exactly like inference. Native
|
|
111
|
+
* Anthropic declarations remain visible (they are routable via OpenAI ingress, not Messages).
|
|
112
|
+
* Marker aliases contribute their target ids; a slot contributes its resolved target, not the
|
|
113
|
+
* Claude source label. Deduplicate by (provider, model); ambiguous declarations without an
|
|
114
|
+
* explicit rule are not routable and are excluded.
|
|
115
|
+
*/
|
|
116
|
+
export function buildModelScores(cfg, dataset) {
|
|
117
|
+
const targets = new Map();
|
|
118
|
+
const add = (model, provider) => {
|
|
119
|
+
if (cfg.providers[provider] && !isJudgeOnlyModel(model))
|
|
120
|
+
targets.set(`${provider}\0${model}`, { model, provider });
|
|
121
|
+
};
|
|
122
|
+
const declared = new Set(Object.values(cfg.providers).flatMap(p => (p.models ?? []).map(m => m.id)));
|
|
123
|
+
const candidates = new Set([...declared, ...Object.values(cfg.aliases), ...cfg.cli.extraModels.map(m => m.model), ...Object.values(cfg.cli.models ?? {}), ...Object.keys(cfg.routes)]);
|
|
124
|
+
// Fallbacks are explicit provider/model targets even when they are absent from declared lists.
|
|
125
|
+
for (const route of Object.values(cfg.routes)) {
|
|
126
|
+
for (const fallback of route.fallbacks ?? []) {
|
|
127
|
+
const provider = cfg.providers[fallback.provider];
|
|
128
|
+
if (provider && (provider.type !== "anthropic" || provider.accountPool))
|
|
129
|
+
add(fallback.model, fallback.provider);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const candidate of candidates) {
|
|
133
|
+
const result = resolve(candidate, {}, cfg);
|
|
134
|
+
if (result) {
|
|
135
|
+
add(result.model, result.provider);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Native ingress has a separate resolver; do not pretend ambiguous translated models route.
|
|
139
|
+
const owners = declaredBy(candidate, cfg);
|
|
140
|
+
if (owners.length === 1 && cfg.providers[owners[0]].type === "anthropic")
|
|
141
|
+
add(candidate.replace(/@[a-z]+$/, ""), owners[0]);
|
|
142
|
+
}
|
|
143
|
+
const groups = new Map();
|
|
144
|
+
for (const variant of dataset?.models ?? []) {
|
|
145
|
+
const group = groups.get(variant.base) ?? [];
|
|
146
|
+
group.push(variant);
|
|
147
|
+
groups.set(variant.base, group);
|
|
148
|
+
}
|
|
149
|
+
const models = [...targets.values()].sort((a, b) => a.model.localeCompare(b.model) || a.provider.localeCompare(b.provider)).map(target => {
|
|
150
|
+
const aaBase = normalizeAaBase(target.model);
|
|
151
|
+
const found = groups.get(aaBase) ?? [];
|
|
152
|
+
return {
|
|
153
|
+
...target, aaBase,
|
|
154
|
+
variants: [...found].sort((a, b) => a.slug.localeCompare(b.slug)).map(v => ({ effort: mapAaEffort(v.effort), intelligence: v.intelligence, costPerTask: v.costPerTask })),
|
|
155
|
+
matched: found.length > 0,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
return { attribution: dataset?.attribution ?? ATTRIBUTION, generatedAt: dataset?.generatedAt ?? null, models };
|
|
159
|
+
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// entry as a template and override the identifying fields. Surfaces are matched by id
|
|
10
10
|
// heuristically (anything that is not the claude.ai chat surface); the ids we saw are
|
|
11
11
|
// logged once so the heuristic can be tightened.
|
|
12
|
+
import { resolve } from "./routing.js";
|
|
12
13
|
import { clampEffort } from "./compat.js";
|
|
14
|
+
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
13
15
|
export const BOOTSTRAP_PATHS = ["/edge-api/bootstrap", "/api/bootstrap"];
|
|
14
16
|
export function isBootstrapPath(path) {
|
|
15
17
|
return BOOTSTRAP_PATHS.some((p) => path === p || path.startsWith(p + "/") || path.startsWith(p + "?"));
|
|
@@ -48,12 +50,17 @@ function narrowThinking(entry, levels) {
|
|
|
48
50
|
const preferred = clampEffort("high", kept.map((option) => option.id));
|
|
49
51
|
entry.thinking = { ...thinking, effort_options: kept.map(({ recommended: _recommended, ...option }) => (option.id === preferred ? { ...option, recommended: true } : option)) };
|
|
50
52
|
}
|
|
53
|
+
/** Resolve aliases/routes before advertising an entry; a chat-looking alias can target a judge. */
|
|
54
|
+
export function chatPickerModels(cfg) {
|
|
55
|
+
return cfg.cli.extraModels.filter(entry => !isJudgeOnlyModel(entry.model) && !isJudgeOnlyModel(resolve(entry.model, {}, cfg)?.model ?? cfg.aliases[entry.model.split("@")[0]] ?? ""));
|
|
56
|
+
}
|
|
51
57
|
/**
|
|
52
58
|
* Mutates `json` in place; returns what was done for logging. `effortFor` names the levels a model
|
|
53
59
|
* takes (by its id without an `@effort` suffix); undefined leaves the template's menu as it is.
|
|
54
60
|
*/
|
|
55
61
|
export function injectPickerModels(json, extra, contextWindow, effortFor) {
|
|
56
62
|
const result = { injected: 0, surfaces: [] };
|
|
63
|
+
extra = extra.filter(e => !isJudgeOnlyModel(e.model));
|
|
57
64
|
const msc = json.model_selector_config;
|
|
58
65
|
if (!Array.isArray(msc) || extra.length === 0)
|
|
59
66
|
return result;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Runtime view of Claude subscription accounts: current Claude Code/Desktop login first
|
|
2
|
-
// ClaudeRipple-owned OAuth accounts. Stored grants are refreshed just before expiry and projected as
|
|
1
|
+
// Runtime view of Claude subscription accounts: current Claude Code/Desktop login first (unless the
|
|
2
|
+
// user marked a stored account preferred, which then leads), followed by ClaudeRipple-owned OAuth accounts. Stored grants are refreshed just before expiry and projected as
|
|
3
3
|
// ordinary proxy credentials, so the shared CredentialPool supplies affinity, cooldown and retry.
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import { redactErrorText } from "../redact.js";
|
|
@@ -115,12 +115,18 @@ export class ClaudeAccountAuthPool {
|
|
|
115
115
|
// first migration write, omit that duplicate instead of trying the same token twice.
|
|
116
116
|
if (currentToken && account.token === currentToken)
|
|
117
117
|
continue;
|
|
118
|
-
|
|
118
|
+
const credential = {
|
|
119
119
|
id: credentialId(account.id, account.token, account.refreshToken),
|
|
120
120
|
ownerId: account.id,
|
|
121
121
|
label: account.label,
|
|
122
122
|
headers: nativeAnthropicHeaders({ type: "anthropic", auth: "claude-code" }, { accessToken: account.token, expiresAt: account.expiresAt }),
|
|
123
|
-
}
|
|
123
|
+
};
|
|
124
|
+
// The pool tries credentials in this order, so the account the user chose goes ahead of the
|
|
125
|
+
// current login. A conversation already held by another account stays there (its cache).
|
|
126
|
+
if (account.preferred)
|
|
127
|
+
out.unshift(credential);
|
|
128
|
+
else
|
|
129
|
+
out.push(credential);
|
|
124
130
|
}
|
|
125
131
|
return out;
|
|
126
132
|
}
|
|
@@ -27,7 +27,8 @@ function validAccount(value) {
|
|
|
27
27
|
&& typeof a.label === "string" && a.label.length > 0
|
|
28
28
|
&& (a.subjectHash === undefined || (typeof a.subjectHash === "string" && /^[0-9a-f]{64}$/.test(a.subjectHash)))
|
|
29
29
|
&& (a.email === undefined || typeof a.email === "string")
|
|
30
|
-
&& (a.needsReauth === undefined || typeof a.needsReauth === "boolean")
|
|
30
|
+
&& (a.needsReauth === undefined || typeof a.needsReauth === "boolean")
|
|
31
|
+
&& (a.preferred === undefined || typeof a.preferred === "boolean");
|
|
31
32
|
}
|
|
32
33
|
function parseClaudeAccountsFile(home) {
|
|
33
34
|
try {
|
|
@@ -178,6 +179,29 @@ export function renameClaudeAccount(home, id, label) {
|
|
|
178
179
|
return true;
|
|
179
180
|
});
|
|
180
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Make one stored account the first one tried, or with `null` hand first place back to the current
|
|
184
|
+
* Claude login. False when the id names no stored account.
|
|
185
|
+
*/
|
|
186
|
+
export function setPreferredClaudeAccount(home, id) {
|
|
187
|
+
return withAccountLock(home, () => {
|
|
188
|
+
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
189
|
+
if (id !== null && !accounts.some((account) => account.id === id))
|
|
190
|
+
return false;
|
|
191
|
+
let changed = false;
|
|
192
|
+
const next = accounts.map((account) => {
|
|
193
|
+
const want = account.id === id;
|
|
194
|
+
if ((account.preferred === true) === want)
|
|
195
|
+
return account;
|
|
196
|
+
changed = true;
|
|
197
|
+
const { preferred: _, ...rest } = account;
|
|
198
|
+
return want ? { ...rest, preferred: true } : rest;
|
|
199
|
+
});
|
|
200
|
+
if (changed)
|
|
201
|
+
writeClaudeAccountsFile(home, next);
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
181
205
|
export function removeClaudeAccount(home, id) {
|
|
182
206
|
return withAccountLock(home, () => {
|
|
183
207
|
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
@@ -209,6 +233,7 @@ export function summarize(account) {
|
|
|
209
233
|
...(account.email ? { email: account.email } : {}),
|
|
210
234
|
expiresAt: account.expiresAt,
|
|
211
235
|
needsReauth: account.needsReauth === true,
|
|
236
|
+
preferred: account.preferred === true,
|
|
212
237
|
};
|
|
213
238
|
}
|
|
214
239
|
export function listClaudeAccounts(home) {
|
|
@@ -11,8 +11,13 @@
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { codexHome } from "../../../../cli/src/codex.js";
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The client_version floor: below this the backend hides models ClaudeRipple already supports.
|
|
16
|
+
* 0.158.0 omits gpt-6.1-sol and 0.159.0 lists it (measured 2026-10-10). A floor always trails the
|
|
17
|
+
* next release, so the newest published Codex version is asked for as well (`latestCodexVersion`):
|
|
18
|
+
* a Windows user with no Codex CLI was asking with this floor and never saw 6.1 Sol (issue #50).
|
|
19
|
+
*/
|
|
20
|
+
export const CODEX_CLIENT_VERSION_FLOOR = "0.159.0";
|
|
16
21
|
/**
|
|
17
22
|
* Copied from the Codex catalog 2026-09-23, used only when the live catalog cannot be read. The
|
|
18
23
|
* catalog lists `ultra` on astra, sol and 5.6 terra/sol and not on either Luna — the reverse of a
|
|
@@ -78,20 +83,46 @@ function compareVersions(a, b) {
|
|
|
78
83
|
/**
|
|
79
84
|
* The version to ask the catalogue with: the installed Codex CLI's, from the cache it writes
|
|
80
85
|
* (`<codexHome>/models_cache.json`, honouring `CODEX_HOME`), but never below the floor — an older
|
|
81
|
-
* CLI must not cost us the models the newer backend would list.
|
|
82
|
-
*
|
|
86
|
+
* CLI must not cost us the models the newer backend would list. `latest` (the newest published
|
|
87
|
+
* Codex) wins when it is higher still. Missing or unreadable file, or a version that is not
|
|
88
|
+
* numeric, answers the floor.
|
|
83
89
|
*/
|
|
84
|
-
export function codexClientVersion(home = codexHome()) {
|
|
85
|
-
let
|
|
90
|
+
export function codexClientVersion(home = codexHome(), latest) {
|
|
91
|
+
let best = CODEX_CLIENT_VERSION_FLOOR;
|
|
92
|
+
const consider = (value) => {
|
|
93
|
+
if (typeof value !== "string" || !/^\d+(\.\d+)*/.test(value))
|
|
94
|
+
return;
|
|
95
|
+
// "0.155.1-nightly.3" is 0.155.1 as far as the backend's filter is concerned.
|
|
96
|
+
const core = value.split("-")[0];
|
|
97
|
+
if (compareVersions(core, best) > 0)
|
|
98
|
+
best = core;
|
|
99
|
+
};
|
|
86
100
|
try {
|
|
87
|
-
|
|
101
|
+
consider(JSON.parse(fs.readFileSync(path.join(home, "models_cache.json"), "utf8")).client_version);
|
|
88
102
|
}
|
|
89
|
-
catch {
|
|
90
|
-
|
|
103
|
+
catch { /* no Codex CLI here */ }
|
|
104
|
+
consider(latest);
|
|
105
|
+
return best;
|
|
106
|
+
}
|
|
107
|
+
const NPM_LATEST = "https://registry.npmjs.org/@openai/codex/latest";
|
|
108
|
+
const LATEST_TTL_MS = 24 * 60 * 60_000;
|
|
109
|
+
const LATEST_RETRY_MS = 60 * 60_000;
|
|
110
|
+
let latestCache = null;
|
|
111
|
+
/**
|
|
112
|
+
* The newest published Codex CLI version, from the npm registry, kept a day; null when the
|
|
113
|
+
* registry cannot be read, which is then not asked again for an hour. Never throws.
|
|
114
|
+
*/
|
|
115
|
+
export async function latestCodexVersion(fetchImpl = fetch, now = Date.now()) {
|
|
116
|
+
if (latestCache && latestCache.until > now)
|
|
117
|
+
return latestCache.version;
|
|
118
|
+
let version = null;
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetchImpl(NPM_LATEST, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(3_000) });
|
|
121
|
+
const value = res.ok ? (await res.json()).version : undefined;
|
|
122
|
+
if (typeof value === "string" && /^\d+\.\d+\.\d+/.test(value))
|
|
123
|
+
version = value;
|
|
91
124
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const core = cached.split("-")[0];
|
|
96
|
-
return compareVersions(core, CODEX_CLIENT_VERSION_FLOOR) > 0 ? core : CODEX_CLIENT_VERSION_FLOOR;
|
|
125
|
+
catch { /* offline or blocked: the floor and the local CLI still answer */ }
|
|
126
|
+
latestCache = { version, until: now + (version ? LATEST_TTL_MS : LATEST_RETRY_MS) };
|
|
127
|
+
return version;
|
|
97
128
|
}
|
|
@@ -8,8 +8,9 @@ import { SseParser } from "./sse.js";
|
|
|
8
8
|
import { fetchWithRetry } from "../retry.js";
|
|
9
9
|
import { looksLikeAuth } from "../openai/index.js";
|
|
10
10
|
import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
|
|
11
|
+
import { providerExhaustion } from "../../health.js";
|
|
11
12
|
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
12
|
-
import { codexClientVersion, parseCodexCatalog } from "./catalog.js";
|
|
13
|
+
import { codexClientVersion, latestCodexVersion, parseCodexCatalog } from "./catalog.js";
|
|
13
14
|
import fs from "node:fs";
|
|
14
15
|
import path from "node:path";
|
|
15
16
|
import { homeDir } from "../../config.js";
|
|
@@ -635,11 +636,12 @@ export class ChatGptAdapter {
|
|
|
635
636
|
this.log.warn(`chatgpt ${this.name}: model-catalog fetch skipped: ${tokens.message}`);
|
|
636
637
|
return null;
|
|
637
638
|
}
|
|
639
|
+
const version = codexClientVersion(undefined, await latestCodexVersion());
|
|
638
640
|
const ac = new AbortController();
|
|
639
641
|
const timer = setTimeout(() => ac.abort(), MODELS_TIMEOUT_MS);
|
|
640
642
|
let res;
|
|
641
643
|
try {
|
|
642
|
-
res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${MODELS_PATH}?client_version=${encodeURIComponent(
|
|
644
|
+
res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${MODELS_PATH}?client_version=${encodeURIComponent(version)}`, {
|
|
643
645
|
method: "GET",
|
|
644
646
|
headers: {
|
|
645
647
|
authorization: `Bearer ${tokens.accessToken}`,
|
|
@@ -1036,7 +1038,9 @@ export class ChatGptAdapter {
|
|
|
1036
1038
|
this.dump(sent.status, json, upstreamReq, sent.text);
|
|
1037
1039
|
}
|
|
1038
1040
|
res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
|
|
1039
|
-
return { status: err.status, bytes: err.body.length, note
|
|
1041
|
+
return { status: err.status, bytes: err.body.length, note,
|
|
1042
|
+
...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
|
|
1043
|
+
};
|
|
1040
1044
|
}
|
|
1041
1045
|
const { upstream, credential } = sent;
|
|
1042
1046
|
const nextTurnState = upstream.headers.get("x-codex-turn-state");
|
|
@@ -1140,6 +1144,7 @@ export class ChatGptAdapter {
|
|
|
1140
1144
|
}
|
|
1141
1145
|
const failure = mapper.failure;
|
|
1142
1146
|
const failedStatus = failure?.type === "invalid_request_error" ? 400 : failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
1147
|
+
const exhaustion = failure ? providerExhaustion(429, `${failure.code ?? ""} ${failure.message}`, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined;
|
|
1143
1148
|
if (!wantStream || (failure && !res.headersSent)) {
|
|
1144
1149
|
// A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
|
|
1145
1150
|
const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
|
|
@@ -1153,6 +1158,7 @@ export class ChatGptAdapter {
|
|
|
1153
1158
|
return {
|
|
1154
1159
|
// A stream has already sent 200; the record still says the turn failed.
|
|
1155
1160
|
status: failure ? failedStatus : 200,
|
|
1161
|
+
exhaustion,
|
|
1156
1162
|
bytes,
|
|
1157
1163
|
note: failure
|
|
1158
1164
|
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
|
|
@@ -114,16 +114,20 @@ const UNSUPPORTED_REGEX = /\(\?[=!<]|\\[0-9]/;
|
|
|
114
114
|
export function unsupportedPattern(p) {
|
|
115
115
|
return UNSUPPORTED_REGEX.test(p);
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
/** Drops the patterns above anywhere in a schema; `removed.count` says how many went. */
|
|
118
|
+
export function scrubSchema(node, removed) {
|
|
118
119
|
if (Array.isArray(node))
|
|
119
|
-
return node.map(scrubSchema);
|
|
120
|
+
return node.map((item) => scrubSchema(item, removed));
|
|
120
121
|
if (typeof node !== "object" || node === null)
|
|
121
122
|
return node;
|
|
122
123
|
const out = {};
|
|
123
124
|
for (const [k, v] of Object.entries(node)) {
|
|
124
|
-
if (k === "pattern" && typeof v === "string" && unsupportedPattern(v))
|
|
125
|
+
if (k === "pattern" && typeof v === "string" && unsupportedPattern(v)) {
|
|
126
|
+
if (removed)
|
|
127
|
+
removed.count++;
|
|
125
128
|
continue;
|
|
126
|
-
|
|
129
|
+
}
|
|
130
|
+
out[k] = scrubSchema(v, removed);
|
|
127
131
|
}
|
|
128
132
|
return out;
|
|
129
133
|
}
|
|
@@ -535,7 +539,7 @@ export class StreamMapper {
|
|
|
535
539
|
const type = contextOverflow ? "invalid_request_error" : code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
|
|
536
540
|
if (contextOverflow)
|
|
537
541
|
message = `prompt is too long: ${message}`;
|
|
538
|
-
this.failure = { type, message };
|
|
542
|
+
this.failure = { type, message, ...(code ? { code } : {}) };
|
|
539
543
|
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
540
544
|
}
|
|
541
545
|
/** Non-streaming body once finished. */
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// vendor sent. The antigravity path picks the account the conversation is sticky to and moves to the
|
|
15
15
|
// next one only on a refusal that has not reached the client (§4 Credential pools).
|
|
16
16
|
import http from "node:http";
|
|
17
|
+
import { providerExhaustion } from "../../health.js";
|
|
17
18
|
import { CredentialPool, retryAfterMs } from "../../pool.js";
|
|
18
19
|
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
19
20
|
import { SseParser } from "../chatgpt/sse.js";
|
|
@@ -320,7 +321,7 @@ export class GoogleAdapter {
|
|
|
320
321
|
this.log.warn(`google ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
|
|
321
322
|
res.off("close", onClose);
|
|
322
323
|
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
323
|
-
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}
|
|
324
|
+
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers)) ?? googleRetryDelayMs(text)) };
|
|
324
325
|
}
|
|
325
326
|
return this.streamBack(res, upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
|
|
326
327
|
}
|
|
@@ -384,7 +385,9 @@ export class GoogleAdapter {
|
|
|
384
385
|
note = `upstream ${sent.status}`;
|
|
385
386
|
}
|
|
386
387
|
res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
|
|
387
|
-
return { status: err.status, bytes: Buffer.byteLength(err.body), note
|
|
388
|
+
return { status: err.status, bytes: Buffer.byteLength(err.body), note,
|
|
389
|
+
...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
|
|
390
|
+
};
|
|
388
391
|
}
|
|
389
392
|
return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
|
|
390
393
|
}
|
|
@@ -484,6 +487,7 @@ export class GoogleAdapter {
|
|
|
484
487
|
const usage = mapper.usage;
|
|
485
488
|
return {
|
|
486
489
|
status: failure ? failedStatus : 200,
|
|
490
|
+
exhaustion: failure ? providerExhaustion(429, failure.message, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined,
|
|
487
491
|
bytes,
|
|
488
492
|
note: failure
|
|
489
493
|
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Responses SSE out, translated back to Anthropic Messages. It never synthesizes
|
|
3
3
|
// cache figures; the request log reflects cached tokens only when the vendor sends them.
|
|
4
4
|
import http from "node:http";
|
|
5
|
+
import { providerExhaustion } from "../../health.js";
|
|
6
|
+
import { retryAfterMs } from "../../pool.js";
|
|
5
7
|
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
6
8
|
import { SseParser } from "../chatgpt/sse.js";
|
|
7
9
|
import { fetchWithRetry } from "../retry.js";
|
|
@@ -146,7 +148,7 @@ export class OpenAiCompatibleAdapter {
|
|
|
146
148
|
this.log.warn(`openai ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
|
|
147
149
|
res.off("close", onClose);
|
|
148
150
|
res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
|
|
149
|
-
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}
|
|
151
|
+
return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers))) };
|
|
150
152
|
}
|
|
151
153
|
const wantStream = json.stream === true;
|
|
152
154
|
const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json));
|
|
@@ -226,6 +228,7 @@ export class OpenAiCompatibleAdapter {
|
|
|
226
228
|
return {
|
|
227
229
|
// A stream has already sent 200; the record still says the turn failed.
|
|
228
230
|
status: failure ? failedStatus : 200,
|
|
231
|
+
exhaustion: failure ? providerExhaustion(429, `${failure.code ?? ""} ${failure.message}`, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined,
|
|
229
232
|
bytes,
|
|
230
233
|
note: failure
|
|
231
234
|
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
|
|
@@ -550,7 +550,7 @@ export class OpenAiStreamMapper {
|
|
|
550
550
|
// `overloaded_error` is the one mid-stream error Claude Code retries on its own (CLI 2.1.278
|
|
551
551
|
// matches `"type":"overloaded_error"` in the message); `api_error` ends the turn for good.
|
|
552
552
|
const type = code === "rate_limit_exceeded" ? "rate_limit_error" : code === "server_is_overloaded" ? "overloaded_error" : "api_error";
|
|
553
|
-
this.failure = { type, message };
|
|
553
|
+
this.failure = { type, message, ...(code ? { code } : {}) };
|
|
554
554
|
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
555
555
|
}
|
|
556
556
|
message() {
|