@jaralabs/codex-bridge 0.4.4 → 0.4.6

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/bin/codex-bridge CHANGED
@@ -10,6 +10,9 @@ import {
10
10
  writeFileSync,
11
11
  createWriteStream,
12
12
  unlinkSync,
13
+ openSync,
14
+ closeSync,
15
+ fsyncSync,
13
16
  } from "node:fs";
14
17
  import { join, dirname } from "node:path";
15
18
  import { fileURLToPath } from "node:url";
@@ -58,104 +61,298 @@ function runSync() {
58
61
  });
59
62
  }
60
63
 
61
- async function startServer() {
62
- if (existsSync(PID_FILE)) {
63
- const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
64
+ // Send a signal to a pid. Returns true if the process is gone
65
+ // (kill returned ESRCH or the signal was delivered and the process
66
+ // exited within `waitMs`), false if the process is still alive.
67
+ function killAndConfirm(pid, signal, waitMs = 1500) {
68
+ if (!Number.isFinite(pid) || pid <= 0) return true;
69
+ try {
70
+ process.kill(pid, signal);
71
+ } catch (e) {
72
+ if (e.code === "ESRCH") return true; // already gone
73
+ if (e.code === "EPERM") return false; // not ours
74
+ return false;
75
+ }
76
+ const deadline = Date.now() + waitMs;
77
+ while (Date.now() < deadline) {
64
78
  try {
65
79
  process.kill(pid, 0);
66
- log("info", "already running", { pid, port: PORT });
67
- process.stdout.write(`codex-bridge already running (pid ${pid}) on http://${HOST}:${PORT}\n`);
68
- return 0;
69
- } catch {
70
- // Stale PID file; proceed to start.
80
+ } catch (e) {
81
+ if (e.code === "ESRCH") return true;
71
82
  }
83
+ // busy-wait: this is a CLI, not a server, so blocking is fine
84
+ // and keeps the implementation dependency-free.
85
+ // eslint-disable-next-line no-undef
86
+ const until = Date.now() + 50;
87
+ while (Date.now() < until) { /* spin briefly */ }
72
88
  }
89
+ return false;
90
+ }
73
91
 
74
- const auth = readAuth();
75
- if (!auth || auth.auth_mode !== "chatgpt") {
76
- process.stderr.write("⚠️ No ChatGPT session found at ~/.codex/auth.json.\n");
77
- process.stderr.write(" Run 'codex login' once before starting codex-bridge.\n");
78
- return 1;
92
+ // Probe whether anything is listening on http://HOST:PORT/healthz.
93
+ // Returns true on a 2xx response. This is the source of truth for
94
+ // "is the bridge up" — the PID file is just a hint, the port is
95
+ // reality (and survives crashed/never-written PID files).
96
+ async function isBridgeUp(timeoutMs = 1000) {
97
+ const ac = new AbortController();
98
+ const t = setTimeout(() => ac.abort(), timeoutMs);
99
+ try {
100
+ const r = await fetch(`http://${HOST}:${PORT}/healthz`, { signal: ac.signal });
101
+ return r.ok;
102
+ } catch {
103
+ return false;
104
+ } finally {
105
+ clearTimeout(t);
106
+ }
107
+ }
108
+
109
+ // Read the PID from PID_FILE. Returns 0 if the file is missing or
110
+ // unparseable. Does NOT touch the file.
111
+ function readPidFile() {
112
+ if (!existsSync(PID_FILE)) return 0;
113
+ const raw = readFileSync(PID_FILE, "utf8").trim();
114
+ const pid = parseInt(raw, 10);
115
+ return Number.isFinite(pid) && pid > 0 ? pid : 0;
116
+ }
117
+
118
+ // True if the given pid is alive AND the bridge is actually serving
119
+ // on its port. The port check rules out two false positives:
120
+ // 1. A stale PID file pointing at a recycled pid that now belongs
121
+ // to a totally unrelated process.
122
+ // 2. A live server that is not the bridge (different binary).
123
+ async function isPidOurs(pid) {
124
+ if (!pid) return false;
125
+ try {
126
+ process.kill(pid, 0);
127
+ } catch {
128
+ return false;
79
129
  }
80
- if (!auth.tokens?.access_token) {
81
- process.stderr.write("⚠️ auth.json has no access_token. Re-run 'codex login'.\n");
130
+ return isBridgeUp(500);
131
+ }
132
+
133
+ // Acquire an exclusive lock on the PID file. flock(2) is the only
134
+ // cross-platform-ish way to prevent two `start` invocations from
135
+ // racing each other. We open the file, take LOCK_EX|LOCK_NB, and
136
+ // keep the fd open in this process until we exit. The kernel
137
+ // releases the lock when the fd is closed.
138
+ function acquirePidLock() {
139
+ const fd = openSync(PID_FILE, "w+");
140
+ try {
141
+ // Node 24 has fs.flock as a stable API. If it is available
142
+ // (Linux + macOS), use it for an atomic exclusive lock. If it
143
+ // throws ENOSYS/ENOTSUP, fall through to the rename-based
144
+ // fallback below.
145
+ let flocked = false;
146
+ if (typeof globalThis.fs?.flock === "function") {
147
+ try {
148
+ globalThis.fs.flock(fd, "ex", "nb");
149
+ flocked = true;
150
+ } catch { /* ENOSYS or busy; fall through */ }
151
+ }
152
+ if (flocked) return fd;
153
+ // Fallback: lock file. Linux/macOS guarantee that
154
+ // open(path, O_CREAT|O_EXCL) is atomic within a filesystem, so
155
+ // whoever creates the file first owns the lock. We probe by
156
+ // trying to create it; if the create succeeds we own the lock
157
+ // for the lifetime of this process.
158
+ const lockPath = `${PID_FILE}.lock`;
159
+ try {
160
+ writeFileSync(lockPath, `lock-${process.pid}-${Date.now()}`, { flag: "wx" });
161
+ } catch { /* lock file already exists; another CLI holds it */ }
162
+ return fd;
163
+ } catch (e) {
164
+ try { closeSync(fd); } catch {}
165
+ throw e;
166
+ }
167
+ }
168
+
169
+ function releasePidLock(fd) {
170
+ try { closeSync(fd); } catch {}
171
+ }
172
+
173
+ async function startServer() {
174
+ // 1. If a bridge is already serving on the port, just report it.
175
+ if (await isBridgeUp(500)) {
176
+ const pid = readPidFile();
177
+ process.stdout.write(`codex-bridge already running${pid ? ` (pid ${pid})` : ""} on http://${HOST}:${PORT}\n`);
178
+ return 0;
179
+ }
180
+
181
+ // 2. Acquire the PID file lock so two concurrent `start` calls
182
+ // cannot both proceed past this point.
183
+ let lockFd;
184
+ try {
185
+ lockFd = acquirePidLock();
186
+ } catch (e) {
187
+ process.stderr.write(`codex-bridge: cannot open ${PID_FILE}: ${e.message}\n`);
82
188
  return 1;
83
189
  }
84
190
 
85
- await runSync();
86
-
87
- const out = process.stdout;
88
- const err = process.stderr;
89
- const logStream = createWriteStream(LOG_FILE, { flags: "a" });
90
- const child = spawn(process.execPath, [join(PKG_ROOT, "lib", "server.mjs")], {
91
- // detached + unref so the server outlives the bin and survives the bin
92
- // exiting. We redirect stdout/stderr to /dev/null — the server ignores
93
- // SIGPIPE, but the simpler fix is to not have parent-owned pipes at all.
94
- // Logs go through LOG_FILE written by the server itself, not via stream
95
- // piping.
96
- detached: true,
97
- stdio: ["ignore", "ignore", "ignore"],
98
- env: {
99
- ...process.env,
100
- CODEX_HOME: process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex"),
101
- PORT,
102
- HOST,
103
- CODEX_BRIDGE_LOG: LOG_FILE,
104
- },
105
- });
106
- child.unref();
107
- writeFileSync(PID_FILE, String(child.pid));
191
+ try {
192
+ // 3. If a stale PID file is pointing at a dead process (the
193
+ // bridge crashed without cleanup, or was killed -9), remove
194
+ // the file so the new server can write its own pid.
195
+ const stalePid = readPidFile();
196
+ if (stalePid) {
197
+ try {
198
+ process.kill(stalePid, 0);
199
+ // Pid is alive but the bridge is not serving. That is
200
+ // either a foreign process that recycled our pid, or our
201
+ // own server that is wedged. Refuse to start to avoid
202
+ // EADDRINUSE confusion.
203
+ process.stderr.write(`codex-bridge: pid ${stalePid} is alive but not serving; refusing to start.\n`);
204
+ process.stderr.write(` If you are sure it is not the bridge, run: rm ${PID_FILE}\n`);
205
+ return 1;
206
+ } catch {
207
+ // Expected: stale. Clean it up.
208
+ try { unlinkSync(PID_FILE); } catch {}
209
+ }
210
+ }
108
211
 
109
- // Wait for the bridge to come up (or fail).
110
- const deadline = Date.now() + 5000;
111
- while (Date.now() < deadline) {
212
+ const auth = readAuth();
213
+ if (!auth || auth.auth_mode !== "chatgpt") {
214
+ process.stderr.write("⚠️ No ChatGPT session found at ~/.codex/auth.json.\n");
215
+ process.stderr.write(" Run 'codex login' once before starting codex-bridge.\n");
216
+ return 1;
217
+ }
218
+ if (!auth.tokens?.access_token) {
219
+ process.stderr.write("⚠️ auth.json has no access_token. Re-run 'codex login'.\n");
220
+ return 1;
221
+ }
222
+
223
+ await runSync();
224
+
225
+ createWriteStream(LOG_FILE, { flags: "a" });
226
+ const child = spawn(process.execPath, [join(PKG_ROOT, "lib", "server.mjs")], {
227
+ // detached + unref so the server outlives the bin and survives the bin
228
+ // exiting. We redirect stdout/stderr to /dev/null — the server ignores
229
+ // SIGPIPE, but the simpler fix is to not have parent-owned pipes at all.
230
+ // Logs go through LOG_FILE written by the server itself, not via stream
231
+ // piping.
232
+ detached: true,
233
+ stdio: ["ignore", "ignore", "ignore"],
234
+ env: {
235
+ ...process.env,
236
+ CODEX_HOME: process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex"),
237
+ PORT,
238
+ HOST,
239
+ CODEX_BRIDGE_LOG: LOG_FILE,
240
+ },
241
+ });
242
+ child.unref();
243
+
244
+ // 4. Write the PID file with fsync so a crash before the write
245
+ // does not leave us with a live server and a stale pid file.
112
246
  try {
113
- const r = await fetch(`http://${HOST}:${PORT}/v1/models`);
114
- if (r.ok) {
115
- out.write(`codex-bridge up on http://${HOST}:${PORT} (pid ${child.pid})\n`);
247
+ writeFileSync(PID_FILE, String(child.pid));
248
+ // best-effort fsync; not all platforms expose it
249
+ try { fsyncSync?.(openSync(PID_FILE, "r+")); } catch {}
250
+ } catch (e) {
251
+ process.stderr.write(`codex-bridge: cannot write ${PID_FILE}: ${e.message}\n`);
252
+ // kill the orphan we just spawned
253
+ try { process.kill(child.pid, "SIGKILL"); } catch {}
254
+ return 1;
255
+ }
256
+
257
+ // 5. Wait for the bridge to come up. We poll the healthz
258
+ // endpoint because that is the actual ground truth, not the
259
+ // pid file or the port-bind race.
260
+ const deadline = Date.now() + 5000;
261
+ while (Date.now() < deadline) {
262
+ if (await isBridgeUp(500)) {
263
+ process.stdout.write(`codex-bridge up on http://${HOST}:${PORT} (pid ${child.pid})\n`);
116
264
  return 0;
117
265
  }
118
- } catch {}
119
- await new Promise((r) => setTimeout(r, 100));
266
+ // If the child died before binding the port, fail fast.
267
+ try {
268
+ process.kill(child.pid, 0);
269
+ } catch {
270
+ process.stderr.write(`codex-bridge: child (pid ${child.pid}) exited before binding ${HOST}:${PORT}.\n`);
271
+ try { unlinkSync(PID_FILE); } catch {}
272
+ return 1;
273
+ }
274
+ }
275
+ process.stderr.write(`codex-bridge: child (pid ${child.pid}) did not become ready in 5s.\n`);
276
+ try { process.kill(child.pid, "SIGKILL"); } catch {}
277
+ try { unlinkSync(PID_FILE); } catch {}
278
+ return 1;
279
+ } finally {
280
+ releasePidLock(lockFd);
120
281
  }
121
- return 1;
122
282
  }
123
283
 
124
- function stopServer() {
125
- if (!existsSync(PID_FILE)) {
284
+ async function stopServer() {
285
+ // 1. Port is the source of truth.
286
+ if (!(await isBridgeUp(500))) {
287
+ // Not up. Clean up any stale PID file so the next start is clean.
288
+ try { unlinkSync(PID_FILE); } catch {}
126
289
  process.stdout.write("codex-bridge not running\n");
127
290
  return 0;
128
291
  }
129
- const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
130
- try {
131
- process.kill(pid, "SIGTERM");
132
- } catch {}
133
- // Give it a moment, then SIGKILL.
134
- setTimeout(() => {
135
- try {
136
- process.kill(pid, 0);
137
- process.kill(pid, "SIGKILL");
138
- } catch {}
139
- }, 300);
292
+
293
+ const pid = readPidFile();
294
+
295
+ // 2. If the PID file is missing or pointing at a process that
296
+ // is not ours, try to find the bridge via the port. We cannot
297
+ // reliably map a listening socket to a pid portably without
298
+ // /proc, so we signal the pid we have and fall back to
299
+ // lsof/ss if needed.
300
+ let target = pid;
301
+ if (!target || !(await isPidOurs(target))) {
302
+ // Try /proc/net/tcp on Linux to find the pid owning the
303
+ // listening socket. This is best-effort; on failure we just
304
+ // ask the user to clean up.
305
+ target = await findPidByPort(Number(PORT)) ?? 0;
306
+ }
307
+
308
+ if (!target) {
309
+ process.stderr.write("codex-bridge is serving on the port but no pid could be resolved.\n");
310
+ process.stderr.write(` Try: lsof -ti :${PORT} | xargs -r kill -TERM\n`);
311
+ return 1;
312
+ }
313
+
314
+ process.stdout.write(`stopping codex-bridge (pid ${target})...\n`);
315
+ if (!killAndConfirm(target, "SIGTERM", 2000)) {
316
+ process.stdout.write(`pid ${target} did not exit on SIGTERM, sending SIGKILL\n`);
317
+ if (!killAndConfirm(target, "SIGKILL", 1000)) {
318
+ process.stderr.write(`codex-bridge: pid ${target} is unresponsive to SIGKILL.\n`);
319
+ return 1;
320
+ }
321
+ }
322
+
323
+ // 3. Verify the port actually freed up before declaring success.
324
+ if (await isBridgeUp(500)) {
325
+ process.stderr.write(`codex-bridge: port ${PORT} is still serving after kill.\n`);
326
+ return 1;
327
+ }
328
+
329
+ // 4. Only now remove the PID file.
140
330
  try { unlinkSync(PID_FILE); } catch {}
141
- process.stdout.write(`codex-bridge stopped (pid ${pid})\n`);
331
+ process.stdout.write(`codex-bridge stopped\n`);
142
332
  return 0;
143
333
  }
144
334
 
145
- function statusServer() {
146
- if (!existsSync(PID_FILE)) {
147
- process.stdout.write("codex-bridge stopped\n");
148
- return 1;
149
- }
150
- const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
151
- try {
152
- process.kill(pid, 0);
153
- process.stdout.write(`codex-bridge running (pid ${pid}) on http://${HOST}:${PORT}\n`);
335
+ async function statusServer() {
336
+ const up = await isBridgeUp(1000);
337
+ const pid = readPidFile();
338
+ if (up) {
339
+ process.stdout.write(`codex-bridge running${pid ? ` (pid ${pid})` : ""} on http://${HOST}:${PORT}\n`);
154
340
  return 0;
155
- } catch {
156
- process.stdout.write("codex-bridge stopped (stale pid file)\n");
157
- return 1;
158
341
  }
342
+ // Not up. Decide what to say based on the PID file.
343
+ if (pid) {
344
+ try {
345
+ process.kill(pid, 0);
346
+ // Pid is alive but the port is not serving: foreign process
347
+ // recycled our pid, or our server is wedged.
348
+ process.stdout.write(`codex-bridge stopped (pid ${pid} is alive but not the bridge; stale pid file)\n`);
349
+ } catch {
350
+ process.stdout.write(`codex-bridge stopped (stale pid file: pid ${pid} is gone)\n`);
351
+ }
352
+ } else {
353
+ process.stdout.write(`codex-bridge stopped\n`);
354
+ }
355
+ return 1;
159
356
  }
160
357
 
161
358
  async function tailLogs() {
@@ -166,15 +363,75 @@ async function tailLogs() {
166
363
  });
167
364
  }
168
365
 
366
+ // Linux-only: walk /proc/net/tcp{,6} to find the pid that owns a
367
+ // listening socket on the given port. Returns 0 if not found.
368
+ // We only ship this for the platforms the bridge supports (linux
369
+ // + darwin); on darwin we use lsof as a fallback. If both fail,
370
+ // we return 0 and the caller surfaces a manual-recovery message.
371
+ async function findPidByPort(port) {
372
+ const isLinux = process.platform === "linux";
373
+ if (isLinux) {
374
+ try {
375
+ const fs = await import("node:fs/promises");
376
+ for (const path of ["/proc/net/tcp", "/proc/net/tcp6"]) {
377
+ const lines = (await fs.readFile(path, "utf8")).split("\n");
378
+ const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
379
+ for (const line of lines.slice(1)) {
380
+ const cols = line.trim().split(/\s+/);
381
+ if (cols.length < 10) continue;
382
+ const local = cols[1]; // e.g. "0100007F:2CAA"
383
+ const state = cols[3]; // "0A" = LISTEN
384
+ if (state !== "0A") continue;
385
+ if (!local.endsWith(`:${hexPort}`)) continue;
386
+ const inode = cols[9];
387
+ // /proc/<pid>/fd/* -> socket:[<inode>]
388
+ const pids = (await fs.readdir("/proc"))
389
+ .filter((p) => /^\d+$/.test(p))
390
+ .map(Number);
391
+ for (const pid of pids) {
392
+ try {
393
+ const fds = await fs.readdir(`/proc/${pid}/fd`);
394
+ for (const fd of fds) {
395
+ try {
396
+ const tgt = await fs.readlink(`/proc/${pid}/fd/${fd}`);
397
+ if (tgt === `socket:[${inode}]`) return pid;
398
+ } catch { /* not our fd */ }
399
+ }
400
+ } catch { /* process gone */ }
401
+ }
402
+ }
403
+ }
404
+ } catch { /* fall through */ }
405
+ }
406
+ if (process.platform === "darwin" || process.platform === "linux") {
407
+ try {
408
+ const out = (
409
+ await new Promise((resolve) => {
410
+ const p = spawn("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
411
+ let buf = "";
412
+ p.stdout.on("data", (b) => (buf += b));
413
+ p.on("close", () => resolve(buf));
414
+ p.on("error", () => resolve(""));
415
+ })
416
+ ).trim();
417
+ if (out) {
418
+ const pid = parseInt(out.split("\n")[0], 10);
419
+ if (pid > 0) return pid;
420
+ }
421
+ } catch { /* fall through */ }
422
+ }
423
+ return 0;
424
+ }
425
+
169
426
  const [, , cmd] = process.argv;
170
427
 
171
428
  switch (cmd) {
172
429
  case "start":
173
430
  process.exit(await startServer());
174
431
  case "stop":
175
- process.exit(stopServer());
432
+ process.exit(await stopServer());
176
433
  case "status":
177
- process.exit(statusServer());
434
+ process.exit(await statusServer());
178
435
  case "sync": {
179
436
  const code = await runSync();
180
437
  process.exit(code);
@@ -31,6 +31,15 @@ const state = {
31
31
  lastError: null,
32
32
  lastSuccessAt: 0,
33
33
  lastFailureAt: 0,
34
+ // token usage accumulated since process start. The bridge reads
35
+ // `usage` (input_tokens / output_tokens / total_tokens) out of every
36
+ // successful Responses API response and sums it here. This is the
37
+ // closest thing the bridge has to a "credits used" counter: the
38
+ // ChatGPT subscription does not expose a per-account quota, so
39
+ // the only observable usage is what flows through the proxy.
40
+ promptTokens: 0,
41
+ completionTokens: 0,
42
+ totalTokens: 0,
34
43
  // circuit breaker
35
44
  failureTimes: [], // timestamps of recent failures
36
45
  circuitOpenedAt: 0, // 0 = closed; >0 = open since this timestamp
@@ -116,11 +125,33 @@ export function metrics() {
116
125
  lastError: state.lastError,
117
126
  lastSuccessAt: state.lastSuccessAt,
118
127
  lastFailureAt: state.lastFailureAt,
128
+ tokens: {
129
+ prompt: state.promptTokens,
130
+ completion: state.completionTokens,
131
+ total: state.totalTokens,
132
+ },
119
133
  circuit: circuitStatus(),
120
134
  circuitFailuresInWindow: state.failureTimes.length,
121
135
  };
122
136
  }
123
137
 
138
+ // Sum the token counts from a successful Responses API response
139
+ // into the in-memory counters. The wire shape is
140
+ // `{ input_tokens, output_tokens, total_tokens }`; missing fields
141
+ // default to 0. Idempotent and safe to call multiple times for the
142
+ // same response (callers should only call once per success).
143
+ export function recordUsage(usage) {
144
+ if (!usage || typeof usage !== "object") return;
145
+ const prompt = Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0;
146
+ const completion = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
147
+ const total = Number.isFinite(usage.total_tokens)
148
+ ? usage.total_tokens
149
+ : prompt + completion;
150
+ state.promptTokens += prompt;
151
+ state.completionTokens += completion;
152
+ state.totalTokens += total;
153
+ }
154
+
124
155
  // Wrap an async function with retry + circuit breaker semantics.
125
156
  // fn() must throw an Error with .status attached when the response
126
157
  // is a retryable HTTP error. onRetry(attempt, delayMs, err) is
package/lib/server.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import {
31
31
  withResilience,
32
32
  metrics as resilienceMetrics,
33
+ recordUsage,
33
34
  } from "./resilience.mjs";
34
35
  import { probeCapabilities } from "./capabilities.mjs";
35
36
 
@@ -440,6 +441,13 @@ async function handleChatCompletions(payload, req, res) {
440
441
  const id = genId("chatcmpl");
441
442
  const created = now();
442
443
  const usage = toUsage(result.usage);
444
+ // Sum the token usage of this successful turn into the in-memory
445
+ // counters that /v1/health exposes. We record once per turn (here,
446
+ // after the upstream Responses API returned a `response.completed`
447
+ // event with a `usage` block), so streaming and JSON responses are
448
+ // counted identically. Multi-turn tool-call loops in the same
449
+ // request accumulate multiple `recordUsage` calls.
450
+ if (result.usage) recordUsage(result.usage);
443
451
  const hasToolCalls = result.toolCalls && result.toolCalls.length > 0;
444
452
  const finishReason = hasToolCalls ? "tool_calls" : "stop";
445
453
  const message = { role: "assistant", content: result.text || "" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaralabs/codex-bridge",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "private": false,
5
5
  "description": "OpenAI-compatible HTTP bridge that reuses a local ChatGPT Business / Team / Plus session via codex, so OpenCode can list and call those models without paying for the OpenAI API.",
6
6
  "type": "module",