clauderipple 0.2.0 → 0.3.1
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 +96 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/cli/src/tray.js +17 -2
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +163 -2
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +713 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// ClaudeRipple-owned pool of Claude subscription OAuth accounts.
|
|
2
|
+
//
|
|
3
|
+
// Secrets live only in <home>/claude-accounts.json (0600). The upstream account UUID is never
|
|
4
|
+
// persisted or returned: a one-way hash is enough to replace the same human account on re-login.
|
|
5
|
+
// The old single-account claude-auth.json is imported atomically on the first pool write so existing
|
|
6
|
+
// installs keep working without keeping two mutable copies of one refresh token.
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { lockSync } from "proper-lockfile";
|
|
11
|
+
import { readClaudeAuthFile, removeClaudeAuthFile } from "./anthropic-token-file.js";
|
|
12
|
+
const LOCK_STALE_MS = 30_000;
|
|
13
|
+
const LOCK_WAIT_MS = 100;
|
|
14
|
+
const LOCK_TIMEOUT_MS = 2_000;
|
|
15
|
+
function withAccountLock(home, mutate) {
|
|
16
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
17
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
18
|
+
let release;
|
|
19
|
+
for (;;) {
|
|
20
|
+
try {
|
|
21
|
+
release = lockSync(claudeAccountsPath(home), {
|
|
22
|
+
realpath: false,
|
|
23
|
+
stale: LOCK_STALE_MS,
|
|
24
|
+
update: 10_000,
|
|
25
|
+
});
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== "ELOCKED" || Date.now() >= deadline) {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return mutate();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
release();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function claudeAccountsPath(home) {
|
|
43
|
+
return path.join(home, "claude-accounts.json");
|
|
44
|
+
}
|
|
45
|
+
function validAccount(value) {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
47
|
+
return false;
|
|
48
|
+
const a = value;
|
|
49
|
+
return typeof a.id === "string" && a.id.length > 0
|
|
50
|
+
&& typeof a.token === "string" && a.token.length > 0
|
|
51
|
+
&& typeof a.refreshToken === "string" && a.refreshToken.length > 0
|
|
52
|
+
&& typeof a.expiresAt === "number" && Number.isFinite(a.expiresAt)
|
|
53
|
+
&& typeof a.createdAt === "string" && typeof a.updatedAt === "string"
|
|
54
|
+
&& typeof a.label === "string" && a.label.length > 0
|
|
55
|
+
&& (a.subjectHash === undefined || (typeof a.subjectHash === "string" && /^[0-9a-f]{64}$/.test(a.subjectHash)))
|
|
56
|
+
&& (a.email === undefined || typeof a.email === "string")
|
|
57
|
+
&& (a.needsReauth === undefined || typeof a.needsReauth === "boolean");
|
|
58
|
+
}
|
|
59
|
+
function parseClaudeAccountsFile(home) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(fs.readFileSync(claudeAccountsPath(home), "utf8"));
|
|
62
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.accounts) || !parsed.accounts.every(validAccount))
|
|
63
|
+
return null;
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
return parsed.accounts.filter((account) => !seen.has(account.id) && seen.add(account.id)).map((account) => ({ ...account }));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return error.code === "ENOENT" ? [] : null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Read only the multi-account file. Malformed secret storage fails closed as an empty pool. */
|
|
72
|
+
export function readClaudeAccountsFile(home) {
|
|
73
|
+
return parseClaudeAccountsFile(home) ?? [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read the pool plus a virtual import of the old single OAuth file. The legacy file is left alone on
|
|
77
|
+
* reads; the next mutation writes it into the pool first and only then removes the old copy.
|
|
78
|
+
*/
|
|
79
|
+
function withLegacyOAuthAccount(home, accounts) {
|
|
80
|
+
const legacy = readClaudeAuthFile(home);
|
|
81
|
+
if (legacy?.source !== "oauth")
|
|
82
|
+
return accounts;
|
|
83
|
+
// If a previous migration wrote the legacy account but crashed before unlinking, do not duplicate
|
|
84
|
+
// it. The refresh token may already have rotated in the new pool, so the durable local id wins.
|
|
85
|
+
if (accounts.some((account) => account.id === "legacy" || account.refreshToken === legacy.refreshToken))
|
|
86
|
+
return accounts;
|
|
87
|
+
return [
|
|
88
|
+
...accounts,
|
|
89
|
+
{
|
|
90
|
+
id: "legacy",
|
|
91
|
+
token: legacy.token,
|
|
92
|
+
refreshToken: legacy.refreshToken,
|
|
93
|
+
expiresAt: legacy.expiresAt,
|
|
94
|
+
createdAt: legacy.createdAt,
|
|
95
|
+
updatedAt: legacy.createdAt,
|
|
96
|
+
label: "Claude account",
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
export function readClaudeOAuthAccounts(home) {
|
|
101
|
+
const accounts = parseClaudeAccountsFile(home);
|
|
102
|
+
if (accounts === null)
|
|
103
|
+
return [];
|
|
104
|
+
// Once a valid pool file exists it is the single source of truth. A retained legacy file (for
|
|
105
|
+
// example where directory fsync is unavailable) must not resurrect an account removed from it.
|
|
106
|
+
return fs.existsSync(claudeAccountsPath(home)) ? accounts : withLegacyOAuthAccount(home, accounts);
|
|
107
|
+
}
|
|
108
|
+
function readClaudeOAuthAccountsForMutation(home) {
|
|
109
|
+
const accounts = parseClaudeAccountsFile(home);
|
|
110
|
+
if (accounts === null)
|
|
111
|
+
throw new Error("Claude account store is unreadable; refusing to overwrite it");
|
|
112
|
+
return fs.existsSync(claudeAccountsPath(home)) ? accounts : withLegacyOAuthAccount(home, accounts);
|
|
113
|
+
}
|
|
114
|
+
/** Windows has no directory fsync, so a flushed file plus successful atomic rename completes migration there. */
|
|
115
|
+
export function legacyMigrationDurable(directoryDurable, platform = process.platform) {
|
|
116
|
+
return directoryDurable || platform === "win32";
|
|
117
|
+
}
|
|
118
|
+
function writeClaudeAccountsFile(home, accounts) {
|
|
119
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
120
|
+
const file = claudeAccountsPath(home);
|
|
121
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
122
|
+
try {
|
|
123
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(fd, JSON.stringify({ version: 1, accounts }, null, 2) + "\n", "utf8");
|
|
126
|
+
fs.fsyncSync(fd);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
fs.closeSync(fd);
|
|
130
|
+
}
|
|
131
|
+
fs.renameSync(tmp, file);
|
|
132
|
+
// Persist the directory entry where the filesystem supports it. POSIX keeps the legacy refresh
|
|
133
|
+
// token unless that succeeds; Windows has no directory fsync, so the flushed file + rename is its
|
|
134
|
+
// durability boundary and must complete migration rather than resurrect a removed legacy account.
|
|
135
|
+
let directoryDurable = false;
|
|
136
|
+
try {
|
|
137
|
+
const dir = fs.openSync(home, "r");
|
|
138
|
+
try {
|
|
139
|
+
fs.fsyncSync(dir);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
fs.closeSync(dir);
|
|
143
|
+
}
|
|
144
|
+
directoryDurable = true;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
const code = error.code;
|
|
148
|
+
if (process.platform !== "win32" && code !== "EINVAL" && code !== "ENOTSUP" && code !== "EBADF")
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const legacy = readClaudeAuthFile(home);
|
|
152
|
+
if (legacyMigrationDurable(directoryDurable) && legacy?.source === "oauth")
|
|
153
|
+
removeClaudeAuthFile(home);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
try {
|
|
157
|
+
fs.unlinkSync(tmp);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code !== "ENOENT")
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function subjectHash(accountId) {
|
|
166
|
+
return accountId ? crypto.createHash("sha256").update(accountId).digest("hex") : undefined;
|
|
167
|
+
}
|
|
168
|
+
function defaultLabel(accounts, email) {
|
|
169
|
+
if (email)
|
|
170
|
+
return email;
|
|
171
|
+
return `Claude account ${accounts.length + 1}`;
|
|
172
|
+
}
|
|
173
|
+
/** Add a distinct account, or atomically replace the same upstream account after re-login. */
|
|
174
|
+
export function saveClaudeOAuthAccount(home, grant, now = new Date().toISOString()) {
|
|
175
|
+
return withAccountLock(home, () => {
|
|
176
|
+
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
177
|
+
const hash = subjectHash(grant.accountId);
|
|
178
|
+
const index = hash ? accounts.findIndex((account) => account.subjectHash === hash) : -1;
|
|
179
|
+
const previous = index >= 0 ? accounts[index] : undefined;
|
|
180
|
+
const account = {
|
|
181
|
+
id: previous?.id ?? crypto.randomUUID(),
|
|
182
|
+
token: grant.accessToken,
|
|
183
|
+
refreshToken: grant.refreshToken,
|
|
184
|
+
expiresAt: grant.expiresAt,
|
|
185
|
+
createdAt: previous?.createdAt ?? now,
|
|
186
|
+
updatedAt: now,
|
|
187
|
+
label: previous?.label ?? defaultLabel(accounts, grant.email),
|
|
188
|
+
...(hash ? { subjectHash: hash } : previous?.subjectHash ? { subjectHash: previous.subjectHash } : {}),
|
|
189
|
+
...(grant.email ? { email: grant.email } : previous?.email ? { email: previous.email } : {}),
|
|
190
|
+
};
|
|
191
|
+
if (index >= 0)
|
|
192
|
+
accounts[index] = account;
|
|
193
|
+
else
|
|
194
|
+
accounts.push(account);
|
|
195
|
+
writeClaudeAccountsFile(home, accounts);
|
|
196
|
+
return summarize(account);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** Compare-and-swap a refreshed grant so an older concurrent refresh cannot overwrite a newer one. */
|
|
200
|
+
export function replaceClaudeOAuthAccount(home, id, expectedRefreshToken, grant, now = new Date().toISOString()) {
|
|
201
|
+
return withAccountLock(home, () => {
|
|
202
|
+
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
203
|
+
const index = accounts.findIndex((account) => account.id === id && account.refreshToken === expectedRefreshToken);
|
|
204
|
+
if (index < 0)
|
|
205
|
+
return false;
|
|
206
|
+
const previous = accounts[index];
|
|
207
|
+
accounts[index] = {
|
|
208
|
+
...previous,
|
|
209
|
+
token: grant.accessToken,
|
|
210
|
+
refreshToken: grant.refreshToken,
|
|
211
|
+
expiresAt: grant.expiresAt,
|
|
212
|
+
updatedAt: now,
|
|
213
|
+
needsReauth: false,
|
|
214
|
+
...(grant.email ? { email: grant.email } : {}),
|
|
215
|
+
};
|
|
216
|
+
writeClaudeAccountsFile(home, accounts);
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Mark only the generation that actually failed; a simultaneous successful login wins. */
|
|
221
|
+
export function markClaudeAccountNeedsReauth(home, id, expectedRefreshToken) {
|
|
222
|
+
return withAccountLock(home, () => {
|
|
223
|
+
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
224
|
+
const index = accounts.findIndex((account) => account.id === id && account.refreshToken === expectedRefreshToken);
|
|
225
|
+
if (index < 0)
|
|
226
|
+
return false;
|
|
227
|
+
accounts[index] = { ...accounts[index], needsReauth: true, updatedAt: new Date().toISOString() };
|
|
228
|
+
writeClaudeAccountsFile(home, accounts);
|
|
229
|
+
return true;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export function renameClaudeAccount(home, id, label) {
|
|
233
|
+
const clean = label.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 80);
|
|
234
|
+
if (!clean)
|
|
235
|
+
return false;
|
|
236
|
+
return withAccountLock(home, () => {
|
|
237
|
+
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
238
|
+
const index = accounts.findIndex((account) => account.id === id);
|
|
239
|
+
if (index < 0)
|
|
240
|
+
return false;
|
|
241
|
+
accounts[index] = { ...accounts[index], label: clean, updatedAt: new Date().toISOString() };
|
|
242
|
+
writeClaudeAccountsFile(home, accounts);
|
|
243
|
+
return true;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
export function removeClaudeAccount(home, id) {
|
|
247
|
+
return withAccountLock(home, () => {
|
|
248
|
+
const accounts = readClaudeOAuthAccountsForMutation(home);
|
|
249
|
+
const next = accounts.filter((account) => account.id !== id);
|
|
250
|
+
if (next.length === accounts.length)
|
|
251
|
+
return false;
|
|
252
|
+
writeClaudeAccountsFile(home, next);
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
export function removeAllClaudeAccounts(home) {
|
|
257
|
+
return withAccountLock(home, () => {
|
|
258
|
+
let removed = removeClaudeAuthFile(home);
|
|
259
|
+
try {
|
|
260
|
+
fs.unlinkSync(claudeAccountsPath(home));
|
|
261
|
+
removed = true;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error.code !== "ENOENT")
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
return removed;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
export function summarize(account) {
|
|
271
|
+
return {
|
|
272
|
+
id: account.id,
|
|
273
|
+
label: account.label,
|
|
274
|
+
...(account.email ? { email: account.email } : {}),
|
|
275
|
+
expiresAt: account.expiresAt,
|
|
276
|
+
needsReauth: account.needsReauth === true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function listClaudeAccounts(home) {
|
|
280
|
+
return readClaudeOAuthAccounts(home).map(summarize);
|
|
281
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The Codex backend's own model catalogue, so a model OpenAI ships appears in ClaudeRipple without
|
|
2
|
+
// a router release.
|
|
3
|
+
//
|
|
4
|
+
// Measured 2026-09-23: GET {base}/codex/models?client_version=<ver> with the subscription's bearer
|
|
5
|
+
// answers `{ models: [{ slug, display_name, visibility: "list"|"hide", supported_in_api,
|
|
6
|
+
// context_window, supported_reasoning_levels: [{ effort, description }] }] }`. The server filters on
|
|
7
|
+
// client_version: 0.146.0 omits the gpt-6-* models that 0.155.0 lists, so the version is read from
|
|
8
|
+
// the installed Codex CLI's cache rather than pinned to whatever was current when this was written.
|
|
9
|
+
// Everything here tolerates a shape change by returning [] — a catalogue we cannot read must leave
|
|
10
|
+
// the caller on its fallback list, not empty the model picker.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
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";
|
|
16
|
+
/**
|
|
17
|
+
* Copied from the Codex catalog 2026-09-23, used only when the live catalog cannot be read. The
|
|
18
|
+
* catalog lists `ultra` on astra, sol and 5.6 terra/sol and not on either Luna — the reverse of a
|
|
19
|
+
* 2026-09-11 measurement (ARCHITECTURE §4); `effortClamp` keeps `ultra` off the wire regardless.
|
|
20
|
+
*/
|
|
21
|
+
export const CHATGPT_FALLBACK_MODELS = [
|
|
22
|
+
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", effortLevels: ["low", "medium", "high", "xhigh", "max", "ultra"], contextWindow: 272000 },
|
|
23
|
+
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", effortLevels: ["low", "medium", "high", "xhigh", "max", "ultra"], contextWindow: 272000 },
|
|
24
|
+
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", effortLevels: ["low", "medium", "high", "xhigh", "max"], contextWindow: 272000 },
|
|
25
|
+
{ id: "gpt-6-astra", name: "GPT-6 Astra", effortLevels: ["low", "medium", "high", "xhigh", "max", "ultra"], contextWindow: 272000 },
|
|
26
|
+
{ id: "gpt-6-sol", name: "GPT-6 Sol", effortLevels: ["low", "medium", "high", "xhigh", "max", "ultra"], contextWindow: 272000 },
|
|
27
|
+
{ id: "gpt-6-luna", name: "GPT-6 Luna", effortLevels: ["low", "medium", "high", "xhigh", "max"], contextWindow: 272000 },
|
|
28
|
+
];
|
|
29
|
+
/** A hyphen that joins a capitalised word ("GPT-6-Sol", "GPT-6-Astra") reads as a space. */
|
|
30
|
+
const JOINING_HYPHEN = /-(?=[A-Z])/g;
|
|
31
|
+
function displayName(slug, display) {
|
|
32
|
+
const raw = typeof display === "string" && display.trim() !== "" ? display.trim() : slug;
|
|
33
|
+
return raw.replace(JOINING_HYPHEN, " ");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The catalogue's `list` entries as provider models. `hide` entries (codex-auto-review,
|
|
37
|
+
* gpt-reserve) are for the CLI's own machinery, not for the user's picker, so they are dropped.
|
|
38
|
+
* Anything unrecognisable yields [].
|
|
39
|
+
*/
|
|
40
|
+
export function parseCodexCatalog(json) {
|
|
41
|
+
const entries = json?.models;
|
|
42
|
+
if (!Array.isArray(entries))
|
|
43
|
+
return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!entry || typeof entry !== "object")
|
|
47
|
+
continue;
|
|
48
|
+
const e = entry;
|
|
49
|
+
if (typeof e.slug !== "string" || e.slug === "" || e.visibility !== "list")
|
|
50
|
+
continue;
|
|
51
|
+
const model = { id: e.slug, name: displayName(e.slug, e.display_name) };
|
|
52
|
+
if (Array.isArray(e.supported_reasoning_levels)) {
|
|
53
|
+
const levels = e.supported_reasoning_levels
|
|
54
|
+
.map((level) => (level && typeof level === "object" ? level.effort : undefined))
|
|
55
|
+
.filter((effort) => typeof effort === "string" && effort !== "");
|
|
56
|
+
// An empty ladder is meaningful to the config (it disables the provider fallback), so it is
|
|
57
|
+
// carried through rather than omitted.
|
|
58
|
+
model.effortLevels = [...new Set(levels)];
|
|
59
|
+
}
|
|
60
|
+
if (typeof e.context_window === "number" && Number.isFinite(e.context_window) && e.context_window > 0)
|
|
61
|
+
model.contextWindow = e.context_window;
|
|
62
|
+
out.push(model);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Numeric major.minor.patch comparison; the prerelease suffix is not part of the version. */
|
|
67
|
+
function compareVersions(a, b) {
|
|
68
|
+
const segments = (v) => v.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
69
|
+
const x = segments(a);
|
|
70
|
+
const y = segments(b);
|
|
71
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
72
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
73
|
+
if (d !== 0)
|
|
74
|
+
return d < 0 ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The version to ask the catalogue with: the installed Codex CLI's, from the cache it writes
|
|
80
|
+
* (`<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.
|
|
83
|
+
*/
|
|
84
|
+
export function codexClientVersion(home = codexHome()) {
|
|
85
|
+
let cached;
|
|
86
|
+
try {
|
|
87
|
+
cached = JSON.parse(fs.readFileSync(path.join(home, "models_cache.json"), "utf8")).client_version;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return CODEX_CLIENT_VERSION_FLOOR;
|
|
91
|
+
}
|
|
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;
|
|
97
|
+
}
|