@nvae/llmswitch 1.2.0 → 1.3.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Cross-process advisory file lock.
3
+ *
4
+ * Used to serialise read-modify-write on the gateway's shared JSON state
5
+ * (rate-limit counters, usage accounting). `llms gateway serve` and the
6
+ * daemon can run at the same time, and both write those files with
7
+ * atomic-replace — without a lock they silently clobber each other's whole
8
+ * file.
9
+ *
10
+ * Locking is best-effort by design: `tryWithFileLock` returns a miss instead of
11
+ * blocking forever, so bookkeeping can degrade rather than stall live traffic.
12
+ */
13
+ import { readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
14
+ import { randomBytes } from "node:crypto";
15
+ import { dirname } from "node:path";
16
+ import { ensureDir } from "./fs.js";
17
+ const DEFAULT_TIMEOUT_MS = 500;
18
+ const DEFAULT_STALE_MS = 5_000;
19
+ const DEFAULT_SPIN_MS = 10;
20
+ function sleepSync(ms) {
21
+ const shared = new Int32Array(new SharedArrayBuffer(4));
22
+ Atomics.wait(shared, 0, 0, ms);
23
+ }
24
+ function pidAlive(pid) {
25
+ if (!Number.isInteger(pid) || pid < 1)
26
+ return false;
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch (err) {
32
+ return err.code === "EPERM";
33
+ }
34
+ }
35
+ function readLockId(path) {
36
+ try {
37
+ const raw = JSON.parse(readFileSync(path, "utf8"));
38
+ return typeof raw.id === "string" ? raw.id : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * A lock is reclaimable when its owner is gone. A dead owner can never release
46
+ * the lock, so that case is reclaimed immediately; when ownership cannot be
47
+ * determined we fall back to an age check that trusts whichever of the file
48
+ * mtime or the recorded timestamp looks older.
49
+ */
50
+ function reclaimable(path, staleMs) {
51
+ let record = {};
52
+ let readable = false;
53
+ try {
54
+ record = JSON.parse(readFileSync(path, "utf8"));
55
+ readable = true;
56
+ }
57
+ catch {
58
+ // Unreadable lock file: fall back to the age check below.
59
+ }
60
+ if (readable && typeof record.pid === "number") {
61
+ return !pidAlive(record.pid);
62
+ }
63
+ let fileAge = Number.POSITIVE_INFINITY;
64
+ try {
65
+ fileAge = Date.now() - statSync(path).mtimeMs;
66
+ }
67
+ catch {
68
+ return true;
69
+ }
70
+ const recordedAge = typeof record.at === "number"
71
+ ? Date.now() - record.at
72
+ : Number.NEGATIVE_INFINITY;
73
+ return Math.max(fileAge, recordedAge) > staleMs;
74
+ }
75
+ function makeLock(path, id) {
76
+ return {
77
+ release() {
78
+ try {
79
+ if (readLockId(path) === id)
80
+ unlinkSync(path);
81
+ }
82
+ catch {
83
+ // Already released or reclaimed.
84
+ }
85
+ },
86
+ };
87
+ }
88
+ /**
89
+ * Acquire `path` as a lock, or return null when it stays busy past the timeout.
90
+ */
91
+ export function acquireFileLock(path, options = {}, now = Date.now()) {
92
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
93
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
94
+ const spinMs = options.spinMs ?? DEFAULT_SPIN_MS;
95
+ const id = randomBytes(8).toString("hex");
96
+ const payload = JSON.stringify({ id, pid: process.pid, at: now });
97
+ const deadline = now + timeoutMs;
98
+ // The directory may not exist yet on a fresh install; without this the
99
+ // exclusive create below fails with ENOENT and persistence never engages.
100
+ try {
101
+ ensureDir(dirname(path));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ for (;;) {
107
+ try {
108
+ writeFileSync(path, payload, { encoding: "utf8", flag: "wx", mode: 0o600 });
109
+ return makeLock(path, id);
110
+ }
111
+ catch (err) {
112
+ if (err.code !== "EEXIST")
113
+ return null;
114
+ }
115
+ if (reclaimable(path, staleMs)) {
116
+ try {
117
+ const tmp = `${path}.${id}.tmp`;
118
+ writeFileSync(tmp, payload, { encoding: "utf8", mode: 0o600 });
119
+ renameSync(tmp, path);
120
+ if (readLockId(path) === id)
121
+ return makeLock(path, id);
122
+ }
123
+ catch {
124
+ // Someone else won the race; fall through and retry.
125
+ }
126
+ }
127
+ if (Date.now() >= deadline)
128
+ return null;
129
+ sleepSync(spinMs);
130
+ }
131
+ }
132
+ /**
133
+ * Run `fn` under the lock. When the lock cannot be taken, `onMiss` decides the
134
+ * fallback (default: run `fn` anyway — callers that must not block traffic).
135
+ */
136
+ export function tryWithFileLock(path, fn, options = {}, now = Date.now()) {
137
+ const lock = acquireFileLock(path, options, now);
138
+ if (!lock) {
139
+ if (options.onMiss)
140
+ return options.onMiss();
141
+ return fn();
142
+ }
143
+ try {
144
+ return fn();
145
+ }
146
+ finally {
147
+ lock.release();
148
+ }
149
+ }
package/dist/utils/fs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync, } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  export function ensureDir(dir) {
@@ -6,31 +6,152 @@ export function ensureDir(dir) {
6
6
  }
7
7
  export function atomicWriteFile(filePath, content, mode = 0o600) {
8
8
  ensureDir(dirname(filePath));
9
- const tmp = join(dirname(filePath), `.${randomBytes(8).toString("hex")}.tmp`);
10
- writeFileSync(tmp, content, { encoding: "utf8", mode });
9
+ const dir = dirname(filePath);
10
+ // 同目录写临时文件再 rename,保证替换是原子的。前缀固定,便于清理历史残留。
11
+ const tmp = join(dir, `${TMP_PREFIX}${randomBytes(8).toString("hex")}.tmp`);
11
12
  try {
12
- chmodSync(tmp, mode);
13
+ writeFileSync(tmp, content, { encoding: "utf8", mode });
14
+ try {
15
+ chmodSync(tmp, mode);
16
+ }
17
+ catch {
18
+ // Windows may ignore mode; continue.
19
+ }
20
+ renameSync(tmp, filePath);
13
21
  }
14
- catch {
15
- // Windows may ignore mode; continue.
22
+ catch (err) {
23
+ // 失败时不要把临时文件留在用户目录里——它可能含明文密钥。
24
+ try {
25
+ if (existsSync(tmp))
26
+ unlinkSync(tmp);
27
+ }
28
+ catch {
29
+ // ignore
30
+ }
31
+ throw err;
16
32
  }
17
- renameSync(tmp, filePath);
18
33
  try {
19
34
  chmodSync(filePath, mode);
20
35
  }
21
36
  catch {
22
37
  // ignore
23
38
  }
39
+ pruneStaleTempFiles(dir);
40
+ }
41
+ const TMP_PREFIX = ".llmswitch-";
42
+ const TMP_STALE_MS = 60 * 60 * 1000;
43
+ /**
44
+ * Write several files as one unit.
45
+ *
46
+ * Adapters often have to update two files together (Codex: config.toml + .env;
47
+ * OpenCode: opencode.json + auth.json). Writing them one by one means a failure
48
+ * on the second leaves the tool pointing at a provider whose credentials were
49
+ * never written. On failure we restore every file already written back to its
50
+ * previous content, or delete it if it did not exist before.
51
+ *
52
+ * Callers must build all contents *before* calling this, so that a build-time
53
+ * throw also leaves nothing half-applied.
54
+ */
55
+ export function writeFilesAtomically(writes) {
56
+ const done = [];
57
+ try {
58
+ for (const write of writes) {
59
+ const previous = existsSync(write.path)
60
+ ? readFileSync(write.path, "utf8")
61
+ : null;
62
+ atomicWriteFile(write.path, write.content, write.mode);
63
+ done.push({ path: write.path, previous });
64
+ }
65
+ }
66
+ catch (err) {
67
+ for (const entry of done.reverse()) {
68
+ try {
69
+ if (entry.previous === null)
70
+ unlinkSync(entry.path);
71
+ else
72
+ atomicWriteFile(entry.path, entry.previous);
73
+ }
74
+ catch {
75
+ // 回滚只能尽力而为;原始错误更重要,继续抛出。
76
+ }
77
+ }
78
+ throw err;
79
+ }
80
+ }
81
+ /**
82
+ * Sweep temp files left behind by a process that died between write and rename.
83
+ * They can contain plaintext API keys, so they must not accumulate in
84
+ * ~/.claude, ~/.codex or ~/.config/opencode.
85
+ */
86
+ function pruneStaleTempFiles(dir) {
87
+ try {
88
+ const now = Date.now();
89
+ for (const name of readdirSync(dir)) {
90
+ if (!name.startsWith(TMP_PREFIX) || !name.endsWith(".tmp"))
91
+ continue;
92
+ const full = join(dir, name);
93
+ if (now - statSync(full).mtimeMs < TMP_STALE_MS)
94
+ continue;
95
+ unlinkSync(full);
96
+ }
97
+ }
98
+ catch {
99
+ // Best effort only; never fail a write because cleanup failed.
100
+ }
24
101
  }
102
+ /** Keep at most this many backups per label, newest first. */
103
+ const MAX_BACKUPS_PER_LABEL = 10;
25
104
  export function backupFile(sourcePath, backupDir, label) {
26
105
  if (!existsSync(sourcePath))
27
106
  return undefined;
28
107
  ensureDir(backupDir);
29
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
30
- const dest = join(backupDir, `${label}-${stamp}.bak`);
108
+ const dest = uniqueBackupPath(backupDir, label);
31
109
  copyFileSync(sourcePath, dest);
110
+ try {
111
+ chmodSync(dest, 0o600);
112
+ }
113
+ catch {
114
+ // ignore
115
+ }
116
+ // 备份里含明文 API Key,且每次 apply/deactivate 都会产生一份。
117
+ // 不设上限的话既会无限占盘,也会让历史密钥永久堆积。
118
+ pruneBackups(backupDir, label);
32
119
  return dest;
33
120
  }
121
+ /**
122
+ * ISO timestamps only have millisecond resolution, and two backups of the same
123
+ * label can easily land in the same millisecond (apply writes config + env
124
+ * back to back). Without a tiebreaker the second copy silently overwrites the
125
+ * first. The suffix is zero-padded so lexicographic order stays time order.
126
+ */
127
+ function uniqueBackupPath(backupDir, label) {
128
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
129
+ const base = join(backupDir, `${label}-${stamp}`);
130
+ if (!existsSync(`${base}.bak`))
131
+ return `${base}.bak`;
132
+ for (let i = 1; i < 1000; i++) {
133
+ const candidate = `${base}-${String(i).padStart(3, "0")}.bak`;
134
+ if (!existsSync(candidate))
135
+ return candidate;
136
+ }
137
+ return `${base}-${randomBytes(4).toString("hex")}.bak`;
138
+ }
139
+ function pruneBackups(backupDir, label) {
140
+ try {
141
+ const prefix = `${label}-`;
142
+ const files = readdirSync(backupDir)
143
+ .filter((name) => name.startsWith(prefix) && name.endsWith(".bak"))
144
+ // 文件名里的时间戳是 ISO 且定长,字典序即时间序。
145
+ .sort()
146
+ .reverse();
147
+ for (const name of files.slice(MAX_BACKUPS_PER_LABEL)) {
148
+ unlinkSync(join(backupDir, name));
149
+ }
150
+ }
151
+ catch {
152
+ // Best effort only.
153
+ }
154
+ }
34
155
  export function maskSecret(value) {
35
156
  if (!value)
36
157
  return "(empty)";
@@ -38,3 +159,60 @@ export function maskSecret(value) {
38
159
  return "****";
39
160
  return `${value.slice(0, 4)}…${value.slice(-4)}`;
40
161
  }
162
+ /**
163
+ * Read + parse a config file we do not own, turning parser failures into an
164
+ * actionable Chinese error instead of a raw SyntaxError. A single stray comma in
165
+ * the user's own ~/.claude/settings.json must not take the whole CLI down with
166
+ * an unreadable stack trace.
167
+ */
168
+ export function readStructuredFile(path, parser, opts) {
169
+ if (!existsSync(path))
170
+ return opts.fallback();
171
+ let text;
172
+ try {
173
+ text = readFileSync(path, "utf8");
174
+ }
175
+ catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ throw new Error(`无法读取${opts.label}「${path}」:${msg}`);
178
+ }
179
+ // 空文件(或只有空白)按“尚未配置”处理,而不是解析失败。
180
+ if (!text.trim())
181
+ return opts.fallback();
182
+ try {
183
+ return parser(text);
184
+ }
185
+ catch (err) {
186
+ const msg = err instanceof Error ? err.message : String(err);
187
+ throw new Error(`${opts.label}「${path}」解析失败:${msg}。` +
188
+ `请修复该文件的语法,或将其移走后重试(本工具会在写入前自动备份)。`);
189
+ }
190
+ }
191
+ /** True only for a real `{...}` object (not null, not an array). */
192
+ export function isPlainObject(value) {
193
+ return (typeof value === "object" && value !== null && !Array.isArray(value));
194
+ }
195
+ /**
196
+ * Spread-safe accessor for a nested table inside someone else's config file.
197
+ * If the key holds a non-object (array/string/number), we must not spread it —
198
+ * that silently produces a garbage structure like {"0":"a","1":"b"}.
199
+ */
200
+ export function plainObjectAt(container, key) {
201
+ const value = container[key];
202
+ return isPlainObject(value) ? value : {};
203
+ }
204
+ /**
205
+ * Read a string map (e.g. an `env` block), dropping non-string values instead of
206
+ * carrying them into a Record<string, string> that the type system trusts.
207
+ */
208
+ export function stringRecordAt(container, key) {
209
+ const source = plainObjectAt(container, key);
210
+ const out = {};
211
+ for (const [k, v] of Object.entries(source)) {
212
+ if (typeof v === "string")
213
+ out[k] = v;
214
+ else if (typeof v === "number" || typeof v === "boolean")
215
+ out[k] = String(v);
216
+ }
217
+ return out;
218
+ }
@@ -1,7 +1,15 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { requestWithNodeTransport } from "../bridge/transport.js";
4
+ import { atomicWriteFile } from "./fs.js";
5
+ import { getAppConfigRoot } from "./paths.js";
2
6
  export const MODEL_METADATA_SOURCE = "https://models.lonae.com";
3
7
  const DEFAULT_ENDPOINT = `${MODEL_METADATA_SOURCE}/api/v1/models`;
4
8
  const PAGE_SIZE = 1000;
9
+ /** Hard cap on pagination; see fetchModelMetadata. */
10
+ const MAX_PAGES = 50;
11
+ /** Metadata changes slowly, so a day-old cache is fine and saves a round trip. */
12
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
5
13
  /** Trailing date stamp, e.g. "-20250219" in "claude-3-7-sonnet-20250219". */
6
14
  const DATE_SUFFIX_RE = /-?\d{8}$/;
7
15
  /**
@@ -151,10 +159,18 @@ export function collectModelMeta(catalog, models) {
151
159
  export async function fetchModelMetadata(options = {}) {
152
160
  const endpoint = options.endpoint || DEFAULT_ENDPOINT;
153
161
  const timeoutMs = options.timeoutMs ?? 15_000;
162
+ // 每次选模型都联网拉全量元数据太重,先看本地缓存。
163
+ if (!options.force) {
164
+ const cached = readMetadataCache(endpoint);
165
+ if (cached)
166
+ return parseModelMetadata({ data: cached });
167
+ }
154
168
  const rows = [];
155
169
  let total = Infinity;
156
170
  let page = 1;
157
- while (rows.length < total) {
171
+ // 页数上限:meta.total 若谎报偏大、而上游又持续返回非空页,
172
+ // 没有这个上限就会一直翻页下去。
173
+ while (rows.length < total && page <= MAX_PAGES) {
158
174
  const url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}page_size=${PAGE_SIZE}&page=${page}`;
159
175
  const payload = await requestJson(url, options.proxy, timeoutMs);
160
176
  const batch = payload && typeof payload === "object"
@@ -169,8 +185,45 @@ export async function fetchModelMetadata(options = {}) {
169
185
  total = typeof totalRaw === "number" && totalRaw > 0 ? totalRaw : rows.length;
170
186
  page += 1;
171
187
  }
188
+ if (rows.length > 0)
189
+ writeMetadataCache(endpoint, rows);
172
190
  return parseModelMetadata({ data: rows });
173
191
  }
192
+ export function getModelMetadataCachePath() {
193
+ return join(getAppConfigRoot(), "model-metadata-cache.json");
194
+ }
195
+ function readMetadataCache(endpoint) {
196
+ const path = getModelMetadataCachePath();
197
+ if (!existsSync(path))
198
+ return null;
199
+ try {
200
+ const raw = JSON.parse(readFileSync(path, "utf8"));
201
+ if (raw?.version !== 1 || raw.endpoint !== endpoint)
202
+ return null;
203
+ if (!Array.isArray(raw.rows) || raw.rows.length === 0)
204
+ return null;
205
+ if (Date.now() - raw.fetchedAt > CACHE_TTL_MS)
206
+ return null;
207
+ return raw.rows;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ function writeMetadataCache(endpoint, rows) {
214
+ try {
215
+ const payload = {
216
+ version: 1,
217
+ endpoint,
218
+ fetchedAt: Date.now(),
219
+ rows,
220
+ };
221
+ atomicWriteFile(getModelMetadataCachePath(), JSON.stringify(payload) + "\n");
222
+ }
223
+ catch {
224
+ // 缓存失败不影响本次结果。
225
+ }
226
+ }
174
227
  async function requestJson(url, proxy, timeoutMs) {
175
228
  const controller = new AbortController();
176
229
  const timer = setTimeout(() => controller.abort(), timeoutMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvae/llmswitch",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI to switch LLM providers and models for Claude Code, Codex, and OpenCode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,11 @@
19
19
  "start": "bun run ./src/index.ts",
20
20
  "test": "bun test",
21
21
  "typecheck": "tsc -p tsconfig.json --noEmit",
22
- "prepublishOnly": "bun run typecheck && bun run build"
22
+ "lint": "biome lint src test scripts",
23
+ "lint:fix": "biome lint --write src test scripts",
24
+ "format": "biome format --write src test scripts",
25
+ "verify": "bun run typecheck && bun run lint && bun test",
26
+ "prepublishOnly": "bun run typecheck && bun run lint && bun run build"
23
27
  },
24
28
  "engines": {
25
29
  "node": ">=20"
@@ -45,6 +49,7 @@
45
49
  "socks-proxy-agent": "8.0.5"
46
50
  },
47
51
  "devDependencies": {
52
+ "@biomejs/biome": "2.3.14",
48
53
  "@types/bun": "^1.2.19",
49
54
  "@types/node": "^24.0.0",
50
55
  "typescript": "^5.8.3"