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.
Files changed (75) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/dist/main.js +23 -3
  9. package/dist/app/package.json +1 -1
  10. package/dist/cli/src/codex.js +3 -1
  11. package/dist/cli/src/index.js +1 -1
  12. package/dist/cli/src/settings.js +5 -2
  13. package/dist/cli/src/tray.js +41 -5
  14. package/dist/mod/.claude-plugin/plugin.json +9 -1
  15. package/dist/mod/hooks/cache.ts +60 -0
  16. package/dist/mod/hooks/compact.ts +40 -0
  17. package/dist/mod/hooks/handoff.ts +43 -0
  18. package/dist/mod/hooks/i18n.ts +2716 -0
  19. package/dist/mod/hooks/judge.ts +226 -0
  20. package/dist/mod/hooks/ladder.js +179 -0
  21. package/dist/mod/hooks/ladder.ts +174 -0
  22. package/dist/mod/hooks/modes.ts +93 -0
  23. package/dist/mod/hooks/register.tsx +1458 -256
  24. package/dist/mod/hooks/session.ts +62 -0
  25. package/dist/mod/hooks/settings.js +342 -0
  26. package/dist/mod/hooks/settings.ts +321 -0
  27. package/dist/mod/hooks/stats.ts +59 -0
  28. package/dist/mod/hooks/ui/agents.tsx +138 -0
  29. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  30. package/dist/mod/hooks/ui/art.ts +628 -0
  31. package/dist/mod/hooks/ui/band.tsx +836 -0
  32. package/dist/mod/hooks/ui/kit.tsx +258 -0
  33. package/dist/mod/hooks/ui/log.tsx +109 -0
  34. package/dist/mod/hooks/ui/parts.tsx +217 -0
  35. package/dist/mod/hooks/ui/ripple.ts +614 -0
  36. package/dist/mod/hooks/ui/settings.tsx +376 -0
  37. package/dist/mod/hooks/ui/setup.tsx +148 -0
  38. package/dist/mod/hooks/ui/theme.ts +217 -0
  39. package/dist/mod/hooks/ui/view.ts +371 -0
  40. package/dist/mod/hooks/worker.ts +1 -1
  41. package/dist/mod/hooks/workers.ts +217 -0
  42. package/dist/mod/tests/register.test.tsx +1730 -0
  43. package/dist/mod/tests/ui.test.tsx +853 -0
  44. package/dist/mod/tsconfig.json +2 -1
  45. package/dist/mod/types/index.d.ts +320 -1
  46. package/dist/router/src/admin.js +195 -53
  47. package/dist/router/src/agents.js +24 -5
  48. package/dist/router/src/bootstrap.js +4 -2
  49. package/dist/router/src/compat.js +10 -2
  50. package/dist/router/src/config.js +3 -0
  51. package/dist/router/src/health.js +33 -0
  52. package/dist/router/src/index.js +1 -0
  53. package/dist/router/src/ingress/models.js +4 -1
  54. package/dist/router/src/mod-settings.js +119 -0
  55. package/dist/router/src/model-policy.js +6 -0
  56. package/dist/router/src/model-scores.js +159 -0
  57. package/dist/router/src/picker.js +7 -0
  58. package/dist/router/src/providers/anthropic-account-pool.js +10 -4
  59. package/dist/router/src/providers/anthropic-accounts.js +26 -1
  60. package/dist/router/src/providers/chatgpt/catalog.js +45 -14
  61. package/dist/router/src/providers/chatgpt/index.js +9 -3
  62. package/dist/router/src/providers/chatgpt/translate.js +9 -5
  63. package/dist/router/src/providers/google/index.js +6 -2
  64. package/dist/router/src/providers/openai/index.js +4 -1
  65. package/dist/router/src/providers/openai/translate.js +1 -1
  66. package/dist/router/src/proxy.js +14 -3
  67. package/dist/router/src/version.js +1 -1
  68. package/dist/ui/app.js +932 -209
  69. package/dist/ui/favicon-32.png +0 -0
  70. package/dist/ui/favicon.svg +12 -0
  71. package/dist/ui/i18n.js +923 -237
  72. package/dist/ui/index.html +159 -87
  73. package/dist/ui/style.css +789 -408
  74. package/docs/ARCHITECTURE.md +134 -2
  75. 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, followed by
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
- out.push({
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
- /** The client_version floor: below this the backend hides models ClaudeRipple already supports. */
15
- export const CODEX_CLIENT_VERSION_FLOOR = "0.155.0";
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. Missing or unreadable file, or a
82
- * version that is not numeric, answers the floor.
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 cached;
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
- cached = JSON.parse(fs.readFileSync(path.join(home, "models_cache.json"), "utf8")).client_version;
101
+ consider(JSON.parse(fs.readFileSync(path.join(home, "models_cache.json"), "utf8")).client_version);
88
102
  }
89
- catch {
90
- return CODEX_CLIENT_VERSION_FLOOR;
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
- if (typeof cached !== "string" || !/^\d+(\.\d+)*/.test(cached))
93
- return CODEX_CLIENT_VERSION_FLOOR;
94
- // "0.155.1-nightly.3" is 0.155.1 as far as the backend's filter is concerned.
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(codexClientVersion())}`, {
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
- function scrubSchema(node) {
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
- out[k] = scrubSchema(v);
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() {