@nvae/llmswitch 0.2.0 → 0.5.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.
@@ -1,29 +1,70 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
4
  import { atomicWriteFile, ensureDir } from "../utils/fs.js";
4
5
  import { getAppConfigRoot } from "../utils/paths.js";
5
6
  import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, emptyUpstreams, } from "./types.js";
7
+ const STATE_VERSION = 2;
8
+ const MAX_PID = 2_147_483_647;
9
+ const LOCK_STALE_MS = 120_000;
10
+ const LOCK_DEFAULT_TIMEOUT_MS = 5_000;
11
+ export class BridgeStateConflictError extends Error {
12
+ constructor(expected, actual) {
13
+ super(`Bridge 状态版本冲突:期望 revision ${expected},实际 ${actual}`);
14
+ this.name = "BridgeStateConflictError";
15
+ }
16
+ }
17
+ export class BridgeLockTimeoutError extends Error {
18
+ constructor(message = "获取 bridge 锁超时") {
19
+ super(message);
20
+ this.name = "BridgeLockTimeoutError";
21
+ }
22
+ }
6
23
  export function getBridgeDir() {
7
24
  return join(getAppConfigRoot(), "bridge");
8
25
  }
9
26
  export function getBridgeStatePath() {
10
27
  return join(getBridgeDir(), "state.json");
11
28
  }
29
+ /** @deprecated v1 single-object upstream file; read once for migration only. */
12
30
  export function getBridgeUpstreamPath() {
13
31
  return join(getBridgeDir(), "upstream.json");
14
32
  }
33
+ /** @deprecated PID is diagnostic only; identity lives in state.instance. */
15
34
  export function getBridgePidPath() {
16
35
  return join(getBridgeDir(), "bridge.pid");
17
36
  }
37
+ export function getBridgeLockPath() {
38
+ return join(getBridgeDir(), "state.lock");
39
+ }
40
+ export function getTransactionsDir() {
41
+ return join(getAppConfigRoot(), "transactions");
42
+ }
43
+ // --- tokens -----------------------------------------------------------------
44
+ /** 32 random bytes as unpadded base64url. */
45
+ export function generateBridgeToken() {
46
+ return randomBytes(32).toString("base64url");
47
+ }
48
+ export function isValidBridgeToken(token) {
49
+ return typeof token === "string" && /^[A-Za-z0-9_-]{43}$/.test(token);
50
+ }
51
+ /** Constant-time token comparison; length mismatch is a fast, safe reject. */
52
+ export function constantTimeTokenEqual(a, b) {
53
+ if (typeof a !== "string" || typeof b !== "string")
54
+ return false;
55
+ const bufA = Buffer.from(a, "utf8");
56
+ const bufB = Buffer.from(b, "utf8");
57
+ if (bufA.length !== bufB.length)
58
+ return false;
59
+ return timingSafeEqual(bufA, bufB);
60
+ }
61
+ // --- upstream normalization -------------------------------------------------
18
62
  function isLegacyUpstream(raw) {
19
63
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
20
64
  return false;
21
65
  const row = raw;
22
66
  return typeof row.baseUrl === "string" && !("codex" in row) && !("claude" in row);
23
67
  }
24
- /**
25
- * Normalize disk/runtime upstream payloads (legacy single object → per-tool map).
26
- */
27
68
  export function normalizeBridgeUpstreams(raw) {
28
69
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
29
70
  return emptyUpstreams();
@@ -37,89 +78,324 @@ export function normalizeBridgeUpstreams(raw) {
37
78
  claude: row.claude ?? null,
38
79
  };
39
80
  }
40
- export function readBridgeUpstreams() {
41
- const path = getBridgeUpstreamPath();
42
- if (!existsSync(path))
43
- return emptyUpstreams();
44
- try {
45
- return normalizeBridgeUpstreams(JSON.parse(readFileSync(path, "utf8")));
46
- }
47
- catch {
48
- return emptyUpstreams();
49
- }
81
+ /** Legacy upstreams cannot authenticate until reapplied. */
82
+ function markUpstreamMigrationRequired(upstream) {
83
+ if (!upstream)
84
+ return null;
85
+ return { ...upstream, clientToken: null, migrationRequired: true };
50
86
  }
51
- export function writeBridgeUpstreams(upstreams) {
52
- ensureDir(getBridgeDir());
53
- atomicWriteFile(getBridgeUpstreamPath(), JSON.stringify({
54
- codex: upstreams.codex,
55
- claude: upstreams.claude,
56
- }, null, 2) + "\n");
87
+ // --- persistence ------------------------------------------------------------
88
+ function isValidPid(pid) {
89
+ return (typeof pid === "number" &&
90
+ Number.isInteger(pid) &&
91
+ pid >= 1 &&
92
+ pid <= MAX_PID);
57
93
  }
58
- export function readBridgeUpstream(tool = "codex") {
59
- return readBridgeUpstreams()[tool];
94
+ function defaultListener() {
95
+ return {
96
+ bindHost: process.env.LLM_SWITCH_BRIDGE_HOST || DEFAULT_BRIDGE_HOST,
97
+ advertiseHost: process.env.LLM_SWITCH_BRIDGE_HOST || DEFAULT_BRIDGE_HOST,
98
+ port: Number(process.env.LLM_SWITCH_BRIDGE_PORT) || DEFAULT_BRIDGE_PORT,
99
+ allowRemote: false,
100
+ };
60
101
  }
61
- export function writeBridgeUpstream(tool, upstream) {
62
- const current = readBridgeUpstreams();
63
- current[tool] = upstream;
64
- writeBridgeUpstreams(current);
65
- const state = readBridgeStateRaw();
66
- writeBridgeState({
102
+ function withFlatAliases(state) {
103
+ return {
67
104
  ...state,
68
- upstreams: current,
69
- });
105
+ port: state.listener.port,
106
+ pid: state.instance?.pid ?? null,
107
+ host: state.listener.bindHost,
108
+ };
70
109
  }
71
- function readBridgeStateRaw() {
72
- const path = getBridgeStatePath();
73
- const defaults = {
74
- port: Number(process.env.LLM_SWITCH_BRIDGE_PORT) || DEFAULT_BRIDGE_PORT,
75
- host: process.env.LLM_SWITCH_BRIDGE_HOST || DEFAULT_BRIDGE_HOST,
76
- pid: null,
77
- upstreams: readBridgeUpstreams(),
110
+ function parseInstance(raw) {
111
+ if (!raw || typeof raw !== "object")
112
+ return null;
113
+ const row = raw;
114
+ if (typeof row.id !== "string" ||
115
+ !isValidBridgeToken(row.controlToken) ||
116
+ !isValidPid(row.pid)) {
117
+ return null;
118
+ }
119
+ return {
120
+ id: row.id,
121
+ controlToken: row.controlToken,
122
+ pid: row.pid,
123
+ startedAt: typeof row.startedAt === "string" ? row.startedAt : "",
124
+ };
125
+ }
126
+ function parseListener(raw) {
127
+ const fallback = defaultListener();
128
+ if (!raw || typeof raw !== "object")
129
+ return fallback;
130
+ const row = raw;
131
+ return {
132
+ bindHost: typeof row.bindHost === "string" ? row.bindHost : fallback.bindHost,
133
+ advertiseHost: typeof row.advertiseHost === "string"
134
+ ? row.advertiseHost
135
+ : fallback.advertiseHost,
136
+ port: typeof row.port === "number" ? row.port : fallback.port,
137
+ allowRemote: row.allowRemote === true,
78
138
  };
79
- if (!existsSync(path))
80
- return defaults;
139
+ }
140
+ /**
141
+ * Read the one-time v1 migration state: legacy upstream.json, flagged so it can
142
+ * be inspected but never authenticate a client until reapplied.
143
+ */
144
+ function readLegacyMigrationState() {
145
+ const legacyPath = getBridgeUpstreamPath();
146
+ if (!existsSync(legacyPath))
147
+ return null;
81
148
  try {
82
- const raw = JSON.parse(readFileSync(path, "utf8"));
83
- const upstreams = raw.upstreams
84
- ? normalizeBridgeUpstreams(raw.upstreams)
85
- : raw.upstream
86
- ? normalizeBridgeUpstreams(raw.upstream)
87
- : readBridgeUpstreams();
88
- return {
89
- port: raw.port || defaults.port,
90
- host: raw.host || defaults.host,
91
- pid: raw.pid ?? null,
92
- upstreams,
93
- };
149
+ const upstreams = normalizeBridgeUpstreams(JSON.parse(readFileSync(legacyPath, "utf8")));
150
+ return withFlatAliases({
151
+ version: STATE_VERSION,
152
+ revision: 0,
153
+ listener: defaultListener(),
154
+ instance: null,
155
+ upstreams: {
156
+ codex: markUpstreamMigrationRequired(upstreams.codex),
157
+ claude: markUpstreamMigrationRequired(upstreams.claude),
158
+ },
159
+ pending: null,
160
+ });
94
161
  }
95
162
  catch {
96
- return defaults;
163
+ return null;
97
164
  }
98
165
  }
99
166
  export function readBridgeState() {
100
- return readBridgeStateRaw();
167
+ const path = getBridgeStatePath();
168
+ if (!existsSync(path)) {
169
+ const legacy = readLegacyMigrationState();
170
+ if (legacy)
171
+ return legacy;
172
+ return withFlatAliases({
173
+ version: STATE_VERSION,
174
+ revision: 0,
175
+ listener: defaultListener(),
176
+ instance: null,
177
+ upstreams: emptyUpstreams(),
178
+ pending: null,
179
+ });
180
+ }
181
+ try {
182
+ const raw = JSON.parse(readFileSync(path, "utf8"));
183
+ const pending = raw.pending && typeof raw.pending === "object"
184
+ ? {
185
+ revision: Number(raw.pending.revision) || 0,
186
+ upstreams: normalizeBridgeUpstreams(raw.pending.upstreams),
187
+ transactionId: raw.pending
188
+ .transactionId,
189
+ }
190
+ : null;
191
+ return withFlatAliases({
192
+ version: STATE_VERSION,
193
+ revision: typeof raw.revision === "number" ? raw.revision : 0,
194
+ listener: parseListener(raw.listener),
195
+ instance: parseInstance(raw.instance),
196
+ upstreams: normalizeBridgeUpstreams(raw.upstreams),
197
+ pending: pending,
198
+ });
199
+ }
200
+ catch {
201
+ return withFlatAliases({
202
+ version: STATE_VERSION,
203
+ revision: 0,
204
+ listener: defaultListener(),
205
+ instance: null,
206
+ upstreams: emptyUpstreams(),
207
+ pending: null,
208
+ });
209
+ }
101
210
  }
102
- export function writeBridgeState(state) {
211
+ function persistState(next) {
103
212
  ensureDir(getBridgeDir());
104
- const upstreams = normalizeBridgeUpstreams(state.upstreams);
105
- atomicWriteFile(getBridgeStatePath(), JSON.stringify({
106
- port: state.port,
107
- host: state.host,
108
- pid: state.pid,
109
- upstreams,
110
- }, null, 2) + "\n");
111
- writeBridgeUpstreams(upstreams);
112
- if (state.pid != null) {
113
- atomicWriteFile(getBridgePidPath(), String(state.pid) + "\n");
114
- }
115
- }
116
- /** Codex-facing base URL (includes /v1). */
213
+ try {
214
+ chmodSync(getBridgeDir(), 0o700);
215
+ }
216
+ catch {
217
+ // best effort; Windows relies on user-dir ACLs
218
+ }
219
+ const payload = {
220
+ version: STATE_VERSION,
221
+ revision: next.revision,
222
+ listener: next.listener,
223
+ instance: next.instance,
224
+ upstreams: {
225
+ codex: next.upstreams.codex,
226
+ claude: next.upstreams.claude,
227
+ },
228
+ pending: next.pending,
229
+ };
230
+ atomicWriteFile(getBridgeStatePath(), JSON.stringify(payload, null, 2) + "\n");
231
+ }
232
+ /**
233
+ * Persist state with an incremented revision. Rejects stale writes: the input
234
+ * revision must equal the on-disk revision (compare-and-set).
235
+ */
236
+ export function writeBridgeState(state) {
237
+ const current = readBridgeState();
238
+ if (existsSync(getBridgeStatePath()) && state.revision !== current.revision) {
239
+ throw new BridgeStateConflictError(current.revision, state.revision);
240
+ }
241
+ const next = withFlatAliases({
242
+ version: STATE_VERSION,
243
+ revision: current.revision + 1,
244
+ listener: state.listener,
245
+ instance: state.instance,
246
+ upstreams: normalizeBridgeUpstreams(state.upstreams),
247
+ pending: state.pending,
248
+ });
249
+ persistState(next);
250
+ return next;
251
+ }
252
+ /**
253
+ * Atomically mutate state under compare-and-set. `expectedRevision` defaults to
254
+ * the current on-disk revision.
255
+ */
256
+ export function updateBridgeState(mutate, expectedRevision) {
257
+ const current = readBridgeState();
258
+ if (expectedRevision !== undefined && expectedRevision !== current.revision) {
259
+ throw new BridgeStateConflictError(current.revision, expectedRevision);
260
+ }
261
+ const mutated = mutate(current);
262
+ const next = withFlatAliases({
263
+ version: STATE_VERSION,
264
+ revision: current.revision + 1,
265
+ listener: mutated.listener,
266
+ instance: mutated.instance,
267
+ upstreams: normalizeBridgeUpstreams(mutated.upstreams),
268
+ pending: mutated.pending,
269
+ });
270
+ persistState(next);
271
+ return next;
272
+ }
273
+ // --- upstream facade (compat) ----------------------------------------------
274
+ export function readBridgeUpstreams() {
275
+ return readBridgeState().upstreams;
276
+ }
277
+ export function readBridgeUpstream(tool = "codex") {
278
+ return readBridgeUpstreams()[tool];
279
+ }
280
+ export function writeBridgeUpstream(tool, upstream) {
281
+ return updateBridgeState((current) => ({
282
+ ...current,
283
+ upstreams: { ...current.upstreams, [tool]: upstream },
284
+ }));
285
+ }
286
+ export function writeBridgeUpstreams(upstreams) {
287
+ return updateBridgeState((current) => ({ ...current, upstreams }));
288
+ }
289
+ // --- URLs -------------------------------------------------------------------
290
+ function hostForUrl(host) {
291
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
292
+ }
293
+ /** Codex-facing base URL (includes /v1), using the connectable advertise host. */
117
294
  export function bridgeBaseUrl(state) {
118
295
  const s = state || readBridgeState();
119
- return `http://${s.host}:${s.port}/v1`;
296
+ return `http://${hostForUrl(s.listener.advertiseHost)}:${s.listener.port}/v1`;
120
297
  }
121
298
  /** Claude-facing root URL (no /v1; client appends /v1/messages). */
122
299
  export function bridgeRootUrl(state) {
123
300
  const s = state || readBridgeState();
124
- return `http://${s.host}:${s.port}`;
301
+ return `http://${hostForUrl(s.listener.advertiseHost)}:${s.listener.port}`;
302
+ }
303
+ function pidAlive(pid) {
304
+ try {
305
+ process.kill(pid, 0);
306
+ return true;
307
+ }
308
+ catch (err) {
309
+ return err.code === "EPERM";
310
+ }
311
+ }
312
+ function hasPendingJournal(transactionId) {
313
+ if (!transactionId)
314
+ return false;
315
+ return existsSync(join(getTransactionsDir(), `${transactionId}.json`));
316
+ }
317
+ function readLockRecord(path) {
318
+ try {
319
+ return JSON.parse(readFileSync(path, "utf8"));
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ }
325
+ /**
326
+ * Acquire the exclusive bridge lock (global lock in the design's lock order).
327
+ * Reclaims a stale lock when: (a) the owner PID is dead and an associated
328
+ * transaction journal exists (immediate journal recovery), or (b) the owner PID
329
+ * is dead and the lock is older than two minutes. Never steals a live lock.
330
+ */
331
+ export function acquireBridgeLock(options = {}) {
332
+ ensureDir(getBridgeDir());
333
+ const lockPath = getBridgeLockPath();
334
+ const deadline = Date.now() + (options.timeoutMs ?? LOCK_DEFAULT_TIMEOUT_MS);
335
+ const lockId = randomBytes(8).toString("hex");
336
+ const record = {
337
+ lockId,
338
+ pid: process.pid,
339
+ instanceId: options.instanceId ?? null,
340
+ transactionId: options.transactionId ?? null,
341
+ createdAt: new Date().toISOString(),
342
+ };
343
+ const serialized = JSON.stringify(record) + "\n";
344
+ for (;;) {
345
+ try {
346
+ writeFileSync(lockPath, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 });
347
+ return makeLock(lockPath, lockId);
348
+ }
349
+ catch (err) {
350
+ if (err.code !== "EEXIST")
351
+ throw err;
352
+ }
353
+ const existing = readLockRecord(lockPath);
354
+ if (existing && reclaimable(existing, lockPath)) {
355
+ // Atomically take over by replacing via temp rename.
356
+ const tmp = `${lockPath}.${lockId}.tmp`;
357
+ writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
358
+ renameSync(tmp, lockPath);
359
+ const confirmed = readLockRecord(lockPath);
360
+ if (confirmed?.lockId === lockId)
361
+ return makeLock(lockPath, lockId);
362
+ }
363
+ if (Date.now() >= deadline) {
364
+ throw new BridgeLockTimeoutError();
365
+ }
366
+ sleepSync(25);
367
+ }
368
+ }
369
+ function reclaimable(record, lockPath) {
370
+ if (isValidPid(record.pid) && pidAlive(record.pid))
371
+ return false;
372
+ // Dead owner with a pending journal: recover immediately.
373
+ if (hasPendingJournal(record.transactionId))
374
+ return true;
375
+ // Orphan lock without a journal: only after the stale age.
376
+ let age = Number.POSITIVE_INFINITY;
377
+ try {
378
+ age = Date.now() - statSync(lockPath).mtimeMs;
379
+ }
380
+ catch {
381
+ age = Number.POSITIVE_INFINITY;
382
+ }
383
+ const recordedAge = record.createdAt
384
+ ? Date.now() - Date.parse(record.createdAt)
385
+ : Number.POSITIVE_INFINITY;
386
+ return Math.max(age, recordedAge) > LOCK_STALE_MS;
387
+ }
388
+ function makeLock(lockPath, lockId) {
389
+ return {
390
+ release() {
391
+ const current = readLockRecord(lockPath);
392
+ if (current?.lockId === lockId) {
393
+ rmSync(lockPath, { force: true });
394
+ }
395
+ },
396
+ };
397
+ }
398
+ function sleepSync(ms) {
399
+ const shared = new Int32Array(new SharedArrayBuffer(4));
400
+ Atomics.wait(shared, 0, 0, ms);
125
401
  }