@jaralabs/codex-bridge 0.1.0 → 0.2.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.
package/bin/codex-bridge CHANGED
@@ -88,19 +88,22 @@ async function startServer() {
88
88
  const err = process.stderr;
89
89
  const logStream = createWriteStream(LOG_FILE, { flags: "a" });
90
90
  const child = spawn(process.execPath, [join(PKG_ROOT, "lib", "server.mjs")], {
91
- stdio: ["ignore", "pipe", "pipe"],
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"],
92
98
  env: {
93
99
  ...process.env,
94
100
  CODEX_HOME: process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex"),
95
101
  PORT,
96
102
  HOST,
103
+ CODEX_BRIDGE_LOG: LOG_FILE,
97
104
  },
98
105
  });
99
- child.stdout.pipe(logStream);
100
- child.stderr.pipe(logStream);
101
- // Also surface stderr to the controlling terminal for the first ~1s so the
102
- // user sees the "listening" banner immediately, then silence stdout.
103
- child.stderr.pipe(err, { end: false });
106
+ child.unref();
104
107
  writeFileSync(PID_FILE, String(child.pid));
105
108
 
106
109
  // Wait for the bridge to come up (or fail).
@@ -110,13 +113,6 @@ async function startServer() {
110
113
  const r = await fetch(`http://${HOST}:${PORT}/v1/models`);
111
114
  if (r.ok) {
112
115
  out.write(`codex-bridge up on http://${HOST}:${PORT} (pid ${child.pid})\n`);
113
- // Now disown: closing stdout/stderr handles would kill the child.
114
- child.stdout.unpipe();
115
- child.stderr.unpipe(err);
116
- child.stdout.unpipe(logStream);
117
- child.stderr.unpipe(logStream);
118
- child.stdout.on("data", () => {});
119
- child.stderr.on("data", () => {});
120
116
  return 0;
121
117
  }
122
118
  } catch {}
package/lib/server.mjs CHANGED
@@ -35,12 +35,24 @@ const codexBin = (() => {
35
35
  return "codex";
36
36
  })();
37
37
 
38
- // ---- Logging (minimal JSONL to stderr) ----
38
+ // ---- Logging (JSONL to stderr AND to LOG_FILE if set) ----
39
+ const LOG_FILE = process.env.CODEX_BRIDGE_LOG || "";
40
+ function writeLog(line) {
41
+ try { process.stderr.write(line + "\n"); } catch {}
42
+ if (LOG_FILE) {
43
+ try {
44
+ appendFileSync(LOG_FILE, line + "\n");
45
+ } catch {}
46
+ }
47
+ }
39
48
  function log(level, msg, extra) {
40
49
  const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
41
- process.stderr.write(line + "\n");
50
+ writeLog(line);
42
51
  }
43
52
 
53
+ import { appendFileSync } from "node:fs";
54
+ process.on("SIGPIPE", () => {});
55
+
44
56
  // ---- Auth handling ----
45
57
  let authCache = null;
46
58
  function loadAuth() {
@@ -130,28 +130,16 @@ if (sameSet(currentSlugs, slugs)) {
130
130
  process.exit(0);
131
131
  }
132
132
 
133
- // ---- 4. Rewrite only the provider block in the file ----
134
- // We rebuild the file by:
135
- // - re-serializing a minimal copy
136
- // - splicing the new block back into the original JSONC at the exact
137
- // position of `provider.chatgpt.models`
138
- // Easier alternative: keep everything else verbatim and replace the models
139
- // block via a regex-friendly region. We do it by:
140
- // 1. compute the slice boundaries of `provider.chatgpt` in the JSONC
141
- // 2. replace it with the rebuilt block
133
+ // ---- 4. Rewrite the provider block, or insert a new one if missing ----
142
134
  //
143
- // For safety with the formatting quirks of JSONC we always preserve the
144
- // indent style of the existing file by sampling indentation from the line
145
- // containing `provider`.
146
-
147
- function detectIndent(src) {
148
- const m = src.match(/^[ \t]+(?=[^\s])/m);
149
- if (!m) return " ";
150
- const prefix = m[0];
151
- if (prefix.includes("\t")) return "\t";
152
- return " ".repeat(prefix.length);
153
- }
154
- const indent = detectIndent(raw);
135
+ // Two cases:
136
+ // (a) `provider.<name>` already exists: rebuild just that block in place,
137
+ // preserving the rest of the file (comments, formatting, sibling keys).
138
+ // (b) `provider.<name>` is absent: append a minimal `provider` object
139
+ // containing only this provider, without disturbing existing keys.
140
+ //
141
+ // For (b) we use a custom dumper (`dump`) at the top-level prefix so the
142
+ // inserted block matches the file's indent style.
155
143
 
156
144
  function renderModelsBlock(models, baseIndent) {
157
145
  const lines = ["{"];
@@ -170,6 +158,131 @@ function renderModelsBlock(models, baseIndent) {
170
158
  return lines.join("\n");
171
159
  }
172
160
 
161
+ function detectIndent(src) {
162
+ const m = src.match(/^[ \t]+(?=[^\s])/m);
163
+ if (!m) return " ";
164
+ const prefix = m[0];
165
+ if (prefix.includes("\t")) return "\t";
166
+ return " ".repeat(prefix.length);
167
+ }
168
+ const indent = detectIndent(raw);
169
+
170
+ // Indent step (best-effort; assume 2 if we can't tell).
171
+ function dump(obj, prefix, step = " ") {
172
+ const lines = [];
173
+ const innerPrefix = prefix + step;
174
+ const keys = Object.keys(obj);
175
+ if (keys.length === 0) return "{}";
176
+ lines.push("{");
177
+ for (let i = 0; i < keys.length; i++) {
178
+ const k = keys[i];
179
+ const v = obj[k];
180
+ const last = i === keys.length - 1;
181
+ const comma = last ? "" : ",";
182
+ if (v && typeof v === "object" && !Array.isArray(v)) {
183
+ lines.push(`${innerPrefix}${JSON.stringify(k)}: ${dump(v, innerPrefix, step)}${comma}`);
184
+ } else {
185
+ lines.push(`${innerPrefix}${JSON.stringify(k)}: ${JSON.stringify(v)}${comma}`);
186
+ }
187
+ }
188
+ lines.push(`${prefix}}`);
189
+ return lines.join("\n");
190
+ }
191
+
192
+ // Append a brand-new top-level `provider` block containing only the requested
193
+ // provider. Does not touch existing sibling keys.
194
+ //
195
+ // Strategy:
196
+ // 1. If the file has an empty `provider: {}`, fill it in.
197
+ // 2. Otherwise, find the last TOP-LEVEL key line (e.g. `"mcp":`) and place
198
+ // `provider: { ... }` directly after its matching `}`, using the same
199
+ // indent prefix as that top-level key.
200
+ function insertProvider(rawText, providerName, providerObj) {
201
+ let body = rawText.replace(/\s+$/, "");
202
+ const fileLE = body.includes("\r\n") ? "\r\n" : "\n";
203
+
204
+ // 1. Empty provider block?
205
+ const emptyProviderRE = new RegExp(`(^|\\n)([ \\t]*)"provider"\\s*:\\s*\\{\\s*\\}\\s*\\n?`);
206
+ if (emptyProviderRE.test(body)) {
207
+ const m = emptyProviderRE.exec(body);
208
+ const topIndent = m[2];
209
+ const wrapped = {};
210
+ wrapped[providerName] = providerObj;
211
+ const block = dump(wrapped, topIndent);
212
+ return body.replace(emptyProviderRE, `$1${topIndent}"provider": ${block}\n`);
213
+ }
214
+
215
+ // 2. Find the last top-level key (one whose closing `}` is followed by
216
+ // the final root `}`). We pick it by walking top-level keys backwards.
217
+ // Each top-level key starts at column indent and ends at the matching
218
+ // `}`.
219
+ const lines = body.split("\n");
220
+ // Find every top-level key (those whose leading whitespace is the smallest
221
+ // amount on the line). Empirically, the file uses 2-space indent so
222
+ // top-level lines start with 0 spaces (root) or 2 spaces.
223
+ const rootDepthIndent = " ";
224
+ let last = null;
225
+ for (let i = 0; i < lines.length; i++) {
226
+ const ln = lines[i];
227
+ const m = ln.match(/^(\s*)\"([^\"]+)\"\s*:\s*\{/);
228
+ if (!m) continue;
229
+ if (m[1] === "") continue; // this is the root `{`
230
+ if (m[1] !== rootDepthIndent) continue; // nested, not top level
231
+ last = { line: i, indent: m[1], key: m[2] };
232
+ }
233
+ if (!last) {
234
+ console.error("sync-cache: could not locate any top-level key to insert after");
235
+ return null;
236
+ }
237
+
238
+ // Walk from `last.line` forward to find the matching `}` of that key. We
239
+ // count nested braces.
240
+ let depth = 0;
241
+ let endIdx = -1;
242
+ let inS = false;
243
+ let q = "";
244
+ for (let i = last.line; i < lines.length; i++) {
245
+ for (let j = 0; j < lines[i].length; j++) {
246
+ const c = lines[i][j];
247
+ if (inS) {
248
+ if (c === "\\") { j++; continue; }
249
+ if (c === q) inS = false;
250
+ continue;
251
+ }
252
+ if (c === '"' || c === "'") { inS = true; q = c; continue; }
253
+ if (c === "{") depth++;
254
+ else if (c === "}") {
255
+ depth--;
256
+ if (depth === 0) { endIdx = i; break; }
257
+ }
258
+ }
259
+ if (endIdx >= 0) break;
260
+ }
261
+ if (endIdx < 0) {
262
+ console.error("sync-cache: could not find closing brace of last top-level key");
263
+ return null;
264
+ }
265
+
266
+ // Rebuild file with the new block inserted after `endIdx`. We MUST add
267
+ // a comma to the previous `}` (which is currently the last sibling of
268
+ // the root) and emit `"provider": { ... }` without a trailing comma
269
+ // because the root `}` will close it.
270
+ const wrapped = {};
271
+ wrapped[providerName] = providerObj;
272
+ const block = dump(wrapped, rootDepthIndent);
273
+ const newLines = lines.slice(0, endIdx + 1);
274
+ // Append a comma to the previous `}` line so the upcoming `"provider":`
275
+ // line is a legal sibling.
276
+ const prev = newLines[newLines.length - 1];
277
+ if (prev !== undefined && prev.includes("}") && !prev.trimEnd().endsWith(",")) {
278
+ newLines[newLines.length - 1] = prev.replace(/}\s*$/, "},");
279
+ }
280
+ newLines.push(`${rootDepthIndent}"provider": ${block}`);
281
+ const tailLines = lines.slice(endIdx + 1);
282
+ newLines.push(...tailLines);
283
+ return newLines.join(fileLE);
284
+ }
285
+
173
286
  function replaceProvider(rawText, providerName, newProviderObj) {
174
287
  // Find the provider's key at whatever indent level it's at.
175
288
  // We capture the leading indent so the replacement is inserted with the
@@ -213,26 +326,6 @@ function replaceProvider(rawText, providerName, newProviderObj) {
213
326
  // Render the new provider block with stable JSON (no comments inside)
214
327
  // using indent sized as outerIndent + step. We rebuild with a custom dumper
215
328
  // so each line carries the correct prefix.
216
- function dump(obj, prefix) {
217
- const lines = [];
218
- const innerPrefix = prefix + step;
219
- const keys = Object.keys(obj);
220
- if (keys.length === 0) return "{}";
221
- lines.push("{");
222
- for (let i = 0; i < keys.length; i++) {
223
- const k = keys[i];
224
- const v = obj[k];
225
- const last = i === keys.length - 1;
226
- const comma = last ? "" : ",";
227
- if (v && typeof v === "object" && !Array.isArray(v)) {
228
- lines.push(`${innerPrefix}${JSON.stringify(k)}: ${dump(v, innerPrefix)}${comma}`);
229
- } else {
230
- lines.push(`${innerPrefix}${JSON.stringify(k)}: ${JSON.stringify(v)}${comma}`);
231
- }
232
- }
233
- lines.push(`${prefix}}`);
234
- return lines.join("\n");
235
- }
236
329
  const replacement = dump(preserved, outerIndent);
237
330
 
238
331
  // Make it match the file's line endings style by normalizing.
@@ -244,18 +337,33 @@ function replaceProvider(rawText, providerName, newProviderObj) {
244
337
 
245
338
  const nextCfg = JSON.parse(JSON.stringify(cfg));
246
339
  if (!nextCfg.provider) nextCfg.provider = {};
247
- if (!nextCfg.provider[PROVIDER]) nextCfg.provider[PROVIDER] = {};
248
- nextCfg.provider[PROVIDER].models = desired;
249
-
250
- // Touch `.options` only if missing — don't overwrite user config.
251
- if (cfg.provider?.[PROVIDER]?.options?.baseURL) {
252
- nextCfg.provider[PROVIDER].options = cfg.provider[PROVIDER].options;
340
+ if (!nextCfg.provider[PROVIDER]) {
341
+ // Case (b): create a minimal provider object from scratch.
342
+ nextCfg.provider[PROVIDER] = {
343
+ npm: "@ai-sdk/openai-compatible",
344
+ name: "ChatGPT Business (codex-bridge)",
345
+ options: { baseURL: "http://127.0.0.1:11434/v1" },
346
+ models: desired,
347
+ };
348
+ } else {
349
+ nextCfg.provider[PROVIDER].models = desired;
350
+ // Touch `.options` only if missing — don't overwrite user config.
351
+ if (!nextCfg.provider[PROVIDER].options?.baseURL) {
352
+ nextCfg.provider[PROVIDER].options = { baseURL: "http://127.0.0.1:11434/v1" };
353
+ }
253
354
  }
254
355
 
255
- const newRaw = replaceProvider(raw, PROVIDER, nextCfg.provider[PROVIDER]);
256
- if (newRaw == null) {
257
- console.error(`sync-cache: provider '${PROVIDER}' not found in config`);
258
- process.exit(1);
356
+ const existingProvider = cfg.provider?.[PROVIDER] || null;
357
+ let newRaw;
358
+ if (existingProvider) {
359
+ newRaw = replaceProvider(raw, PROVIDER, nextCfg.provider[PROVIDER]);
360
+ if (newRaw == null) {
361
+ console.error(`sync-cache: provider '${PROVIDER}' not found in config`);
362
+ process.exit(1);
363
+ }
364
+ } else {
365
+ // Append a `provider: { <name>: {...} }` block to the root object.
366
+ newRaw = insertProvider(raw, PROVIDER, nextCfg.provider[PROVIDER]);
259
367
  }
260
368
 
261
369
  // Validate the new file as JSONC-roundtrip.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaralabs/codex-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",