@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 +9 -13
- package/lib/server.mjs +14 -2
- package/lib/sync-cache.mjs +159 -51
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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() {
|
package/lib/sync-cache.mjs
CHANGED
|
@@ -130,28 +130,16 @@ if (sameSet(currentSlugs, slugs)) {
|
|
|
130
130
|
process.exit(0);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// ---- 4. Rewrite
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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])
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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.
|
|
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",
|