@jaralabs/codex-bridge 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Jaramillo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # @jaralabs/codex-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Use your ChatGPT Business / Team / Plus subscription from OpenCode instead of paying for the OpenAI API.
4
+
5
+ `codex-bridge` runs an OpenAI-compatible HTTP server on `127.0.0.1:11434` that
6
+ forwards chat-completion requests to the [OpenAI Codex CLI][codex]. Because
7
+ the Codex CLI reuses the local ChatGPT OAuth session in `~/.codex/auth.json`,
8
+ your OpenCode prompts consume your subscription credits — not the OpenAI API
9
+ balance.
10
+
11
+ [codex]: https://github.com/openai/codex
12
+
13
+ ## Why
14
+
15
+ If you have a ChatGPT Business / Team / Plus plan, the OpenAI API and the
16
+ ChatGPT web subscription are billed separately. You can use Codex CLI / web
17
+ App today with the subscription. `codex-bridge` exposes the same models over
18
+ an OpenAI-compatible HTTP surface so OpenCode (and any other OpenAI client
19
+ like Claude Code, Cline, aider) can use them without an API key.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -g @jaralabs/codex-bridge
25
+ ```
26
+
27
+ Requires Node 18 or newer and the OpenAI Codex CLI installed and logged in
28
+ once with `codex login` on a machine with the ChatGPT Business / Team / Plus
29
+ session.
30
+
31
+ ## Wire it into OpenCode
32
+
33
+ Add a `chatgpt` provider to `~/.config/opencode/opencode.jsonc`:
34
+
35
+ ```json
36
+ {
37
+ "provider": {
38
+ "chatgpt": {
39
+ "npm": "@ai-sdk/openai-compatible",
40
+ "name": "ChatGPT Business (codex-bridge)",
41
+ "options": {
42
+ "baseURL": "http://127.0.0.1:11434/v1"
43
+ },
44
+ "models": {
45
+ "gpt-6.1-sol": {
46
+ "name": "GPT-6.1 Sol (Business)",
47
+ "limit": { "context": 272000, "output": 128000 },
48
+ "modalities": { "input": ["text", "image"], "output": ["text"] }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ The exact model list depends on what your plan exposes. `codex-bridge start`
57
+ syncs the model list from `~/.codex/models_cache.json` (refreshed by Codex
58
+ whenever it talks to ChatGPT) and rewrites the `provider.chatgpt.models`
59
+ block to match. Re-run `codex-bridge start` after any plan change.
60
+
61
+ ## Usage
62
+
63
+ ```bash
64
+ codex-bridge start # sync models + start HTTP server in background
65
+ codex-bridge status # is it up?
66
+ codex-bridge sync # only sync model list (no server)
67
+ codex-bridge logs # tail the server log
68
+ codex-bridge stop # kill the server
69
+ ```
70
+
71
+ In another terminal:
72
+
73
+ ```bash
74
+ opencode run --model=chatgpt/gpt-6.1-sol "say hi"
75
+ ```
76
+
77
+ ## How it works
78
+
79
+ ```
80
+ ┌──────────┐ HTTP (OpenAI-compatible) ┌────────────────┐
81
+ │ OpenCode │ ──────────────────────────────────────▶ │ codex-bridge │
82
+ │ │ ◀────────────────────────────────────── │ 127.0.0.1:11434│
83
+ └──────────┘ └────────────────┘
84
+ │
85
+ spawns ▼
86
+ ┌─────────────────┐
87
+ │ codex exec │
88
+ │ (uses ChatGPT │
89
+ │ OAuth session) │
90
+ └─────────────────┘
91
+ ```
92
+
93
+ The bridge has zero state of its own. It just maps an OpenAI-style HTTP
94
+ request into a Codex CLI invocation and pipes the result back. Codex handles
95
+ auth, plan routing, and rate limits.
96
+
97
+ ## License
98
+
99
+ MIT.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // codex-bridge CLI: entry point for `codex-bridge start|stop|status|sync|logs`.
3
+ // Resolves the package root via npm CLI conventions so paths work whether the
4
+ // package is installed globally, locally, or run from a checkout.
5
+
6
+ import { spawn } from "node:child_process";
7
+ import {
8
+ existsSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ createWriteStream,
12
+ unlinkSync,
13
+ } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
19
+ const PKG_ROOT = dirname(__dirname);
20
+
21
+ const PORT = process.env.PORT || "11434";
22
+ const HOST = process.env.HOST || "127.0.0.1";
23
+ const PID_FILE = process.env.CODEX_BRIDGE_PID || "/tmp/codex-bridge.pid";
24
+ const LOG_FILE = process.env.CODEX_BRIDGE_LOG || "/tmp/codex-bridge.log";
25
+
26
+ function log(level, msg, extra) {
27
+ const line = JSON.stringify({
28
+ t: new Date().toISOString(),
29
+ src: "codex-bridge",
30
+ level,
31
+ msg,
32
+ ...(extra || {}),
33
+ });
34
+ process.stderr.write(line + "\n");
35
+ }
36
+
37
+ function readAuth() {
38
+ // Sanity-check that codex is logged in. We don't use the tokens
39
+ // directly — `codex exec` does — but we want to fail fast with a
40
+ // helpful message if the user hasn't run `codex login`.
41
+ const home = process.env.HOME || "/tmp";
42
+ const authPath = join(home, ".codex", "auth.json");
43
+ if (!existsSync(authPath)) return null;
44
+ try {
45
+ return JSON.parse(readFileSync(authPath, "utf8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function runSync() {
52
+ return new Promise((resolve) => {
53
+ const child = spawn(process.execPath, [join(PKG_ROOT, "lib", "sync-cache.mjs")], {
54
+ stdio: "inherit",
55
+ env: { ...process.env, CODEX_HOME: process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex") },
56
+ });
57
+ child.on("close", (code) => resolve(code ?? 0));
58
+ });
59
+ }
60
+
61
+ async function startServer() {
62
+ if (existsSync(PID_FILE)) {
63
+ const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
64
+ try {
65
+ 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.
71
+ }
72
+ }
73
+
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;
79
+ }
80
+ if (!auth.tokens?.access_token) {
81
+ process.stderr.write("⚠️ auth.json has no access_token. Re-run 'codex login'.\n");
82
+ return 1;
83
+ }
84
+
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
+ stdio: ["ignore", "pipe", "pipe"],
92
+ env: {
93
+ ...process.env,
94
+ CODEX_HOME: process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex"),
95
+ PORT,
96
+ HOST,
97
+ },
98
+ });
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 });
104
+ writeFileSync(PID_FILE, String(child.pid));
105
+
106
+ // Wait for the bridge to come up (or fail).
107
+ const deadline = Date.now() + 5000;
108
+ while (Date.now() < deadline) {
109
+ try {
110
+ const r = await fetch(`http://${HOST}:${PORT}/v1/models`);
111
+ if (r.ok) {
112
+ 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
+ return 0;
121
+ }
122
+ } catch {}
123
+ await new Promise((r) => setTimeout(r, 100));
124
+ }
125
+ return 1;
126
+ }
127
+
128
+ function stopServer() {
129
+ if (!existsSync(PID_FILE)) {
130
+ process.stdout.write("codex-bridge not running\n");
131
+ return 0;
132
+ }
133
+ const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
134
+ try {
135
+ process.kill(pid, "SIGTERM");
136
+ } catch {}
137
+ // Give it a moment, then SIGKILL.
138
+ setTimeout(() => {
139
+ try {
140
+ process.kill(pid, 0);
141
+ process.kill(pid, "SIGKILL");
142
+ } catch {}
143
+ }, 300);
144
+ try { unlinkSync(PID_FILE); } catch {}
145
+ process.stdout.write(`codex-bridge stopped (pid ${pid})\n`);
146
+ return 0;
147
+ }
148
+
149
+ function statusServer() {
150
+ if (!existsSync(PID_FILE)) {
151
+ process.stdout.write("codex-bridge stopped\n");
152
+ return 1;
153
+ }
154
+ const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
155
+ try {
156
+ process.kill(pid, 0);
157
+ process.stdout.write(`codex-bridge running (pid ${pid}) on http://${HOST}:${PORT}\n`);
158
+ return 0;
159
+ } catch {
160
+ process.stdout.write("codex-bridge stopped (stale pid file)\n");
161
+ return 1;
162
+ }
163
+ }
164
+
165
+ async function tailLogs() {
166
+ const tail = spawn("tail", ["-n", "100", "-f", LOG_FILE], { stdio: "inherit" });
167
+ await new Promise((resolve) => {
168
+ tail.on("exit", resolve);
169
+ process.on("SIGINT", () => tail.kill("SIGTERM"));
170
+ });
171
+ }
172
+
173
+ const [, , cmd] = process.argv;
174
+
175
+ switch (cmd) {
176
+ case "start":
177
+ process.exit(await startServer());
178
+ case "stop":
179
+ process.exit(stopServer());
180
+ case "status":
181
+ process.exit(statusServer());
182
+ case "sync": {
183
+ const code = await runSync();
184
+ process.exit(code);
185
+ }
186
+ case "logs":
187
+ await tailLogs();
188
+ process.exit(0);
189
+ default: {
190
+ process.stderr.write("usage: codex-bridge {start|stop|status|sync|logs}\n");
191
+ process.exit(2);
192
+ }
193
+ }
package/lib/plugin.ts ADDED
@@ -0,0 +1,95 @@
1
+ // codex-bridge OpenCode plugin template.
2
+ //
3
+ // Install: copy this file to ~/.config/opencode/plugins/codex-bridge-models.ts
4
+ // or run `codex-bridge install-plugin` (TODO).
5
+ //
6
+ // Why a plugin: opencode 2.x doesn't hot-reload provider models when opencode.jsonc
7
+ // changes on disk for short-lived CLI invocations like `opencode run`. In a
8
+ // long-lived TUI server, opencode does reload, and the plugin on `session.created`
9
+ // is purely belt-and-suspenders.
10
+ //
11
+ // The plugin reads ~/.codex/models_cache.json and writes the resulting model list
12
+ // into cfg.provider.chatgpt.models. On TUI restart the change will already be
13
+ // present in opencode.jsonc thanks to lib/sync-cache.mjs running from
14
+ // `codex-bridge start`, so this hook is mostly a no-op.
15
+
16
+ import { spawnSync } from "node:child_process"
17
+ import { existsSync, readFileSync } from "node:fs"
18
+ import { join } from "node:path"
19
+
20
+ const CODEX_HOME = process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex")
21
+ const CACHE = join(CODEX_HOME, "models_cache.json")
22
+ const PROVIDER_ID = "chatgpt"
23
+
24
+ const VISIBLE = new Set(["list", "default", null, undefined])
25
+
26
+ function readCache(): string[] {
27
+ if (!existsSync(CACHE)) return []
28
+ try {
29
+ const cache = JSON.parse(readFileSync(CACHE, "utf8"))
30
+ if (!Array.isArray(cache.models)) return []
31
+ return cache.models
32
+ .filter((m: any) => VISIBLE.has(m.visibility ?? null))
33
+ .filter((m: any) => m.supported_in_api !== false)
34
+ .map((m: any) => m.slug)
35
+ .filter((s: unknown): s is string => typeof s === "string" && s.length > 0)
36
+ .sort()
37
+ } catch {
38
+ return []
39
+ }
40
+ }
41
+
42
+ function buildModels(slugs: string[]): Record<string, any> {
43
+ const out: Record<string, any> = {}
44
+ for (const slug of slugs) {
45
+ out[slug] = {
46
+ name: `${slug} (Business)`,
47
+ limit: { context: 272000, output: 128000 },
48
+ modalities: { input: ["text", "image"], output: ["text"] },
49
+ }
50
+ }
51
+ return out
52
+ }
53
+
54
+ function mergeModels(provider: any, slugs: string[]): void {
55
+ if (!provider) return
56
+ const desired = buildModels(slugs)
57
+ const current = (provider.models ?? {}) as Record<string, unknown>
58
+ let same = Object.keys(current).length === slugs.length
59
+ if (same) {
60
+ const curSet = new Set(Object.keys(current))
61
+ for (const s of slugs) if (!curSet.has(s)) { same = false; break }
62
+ }
63
+ if (!same) provider.models = desired
64
+ }
65
+
66
+ const CodexBridgeModelsPlugin = {
67
+ id: "codex-bridge-models",
68
+ async setup(_ctx: unknown) {
69
+ return {
70
+ config: async (cfg: any) => {
71
+ const slugs = readCache()
72
+ if (slugs.length === 0) return
73
+ if (!cfg.provider) cfg.provider = {}
74
+ if (!cfg.provider[PROVIDER_ID]) {
75
+ cfg.provider[PROVIDER_ID] = {
76
+ npm: "@ai-sdk/openai-compatible",
77
+ name: "ChatGPT Business (codex-bridge)",
78
+ options: { baseURL: "http://127.0.0.1:11434/v1" },
79
+ models: {},
80
+ }
81
+ }
82
+ mergeModels(cfg.provider[PROVIDER_ID], slugs)
83
+ },
84
+ event: async ({ event }: { event: { type?: string } }) => {
85
+ if (event?.type === "session.created") {
86
+ // Optional: re-sync at session start. The on-disk JSON file is already
87
+ // kept up to date by lib/sync-cache.mjs when codex-bridge start runs.
88
+ }
89
+ },
90
+ }
91
+ },
92
+ }
93
+
94
+ export default CodexBridgeModelsPlugin
95
+ export { CodexBridgeModelsPlugin }
package/lib/server.mjs ADDED
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // codex-bridge: OpenAI-compatible HTTP server that reuses a local ChatGPT
3
+ // Business/Team/Plus session by shelling out to `codex exec --json`.
4
+ // Default port: 11434. Endpoints:
5
+ // GET /v1/models -> { object:"list", data:[...] }
6
+ // POST /v1/chat/completions -> OpenAI-style streaming or JSON response
7
+ //
8
+ // Auth: reads ~/.codex/auth.json (refreshes access_token via OpenAI's
9
+ // refresh_token grant when expired). The token is only used so the
10
+ // shell session stays authenticated; codex CLI handles the actual call.
11
+
12
+ import http from "node:http";
13
+ import { spawn } from "node:child_process";
14
+ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
15
+ import { homedir, userInfo } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+
18
+ const PORT = Number(process.env.PORT || 11434);
19
+ const HOST = process.env.HOST || "127.0.0.1";
20
+ const CODEX_HOME =
21
+ process.env.CODEX_HOME || join(homedir(), ".codex");
22
+ const AUTH_PATH = join(CODEX_HOME, "auth.json");
23
+ const MODELS_CACHE = join(CODEX_HOME, "models_cache.json");
24
+
25
+ const codexBin = (() => {
26
+ const fromEnv = process.env.CODEX_BIN;
27
+ if (fromEnv) return fromEnv;
28
+ try {
29
+ const which = spawn("which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] });
30
+ const out = [];
31
+ which.stdout.on("data", (c) => out.push(c));
32
+ which.on("close", () => process.stdout.write(""));
33
+ // Note: ignored; we instead use the env-default path lookup below.
34
+ } catch {}
35
+ return "codex";
36
+ })();
37
+
38
+ // ---- Logging (minimal JSONL to stderr) ----
39
+ function log(level, msg, extra) {
40
+ const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
41
+ process.stderr.write(line + "\n");
42
+ }
43
+
44
+ // ---- Auth handling ----
45
+ let authCache = null;
46
+ function loadAuth() {
47
+ if (!existsSync(AUTH_PATH)) {
48
+ throw new Error(`codex-bridge: no auth.json at ${AUTH_PATH}. Run 'codex login' first.`);
49
+ }
50
+ const raw = JSON.parse(readFileSync(AUTH_PATH, "utf8"));
51
+ if (raw.auth_mode !== "chatgpt") {
52
+ throw new Error(`codex-bridge: auth mode is '${raw.auth_mode}', need 'chatgpt'.`);
53
+ }
54
+ if (!raw.tokens?.access_token) throw new Error("codex-bridge: missing tokens.");
55
+ return raw;
56
+ }
57
+ function getAuth() {
58
+ if (!authCache) authCache = loadAuth();
59
+ return authCache;
60
+ }
61
+ // Token refresh is delegated to codex itself; we just rely on the CLI.
62
+
63
+ // ---- Models listing ----
64
+ function listModels() {
65
+ const slugId = (s) => ({
66
+ id: s,
67
+ object: "model",
68
+ created: 0,
69
+ owned_by: "openai-chatgpt",
70
+ });
71
+ let slugs = [];
72
+ if (existsSync(MODELS_CACHE)) {
73
+ try {
74
+ const cache = JSON.parse(readFileSync(MODELS_CACHE, "utf8"));
75
+ if (Array.isArray(cache.models)) {
76
+ slugs = cache.models
77
+ .filter((m) => m.visibility === "list" || m.visibility == null)
78
+ .map((m) => m.slug)
79
+ .filter(Boolean);
80
+ }
81
+ } catch (e) {
82
+ log("warn", "models_cache.json parse failed", { err: String(e) });
83
+ }
84
+ }
85
+ if (slugs.length === 0) {
86
+ slugs = ["gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-6-astra"];
87
+ }
88
+ return { object: "list", data: slugs.map(slugId) };
89
+ }
90
+
91
+ // ---- codex exec runner ----
92
+ function runCodex({ model, prompt, signal }) {
93
+ return new Promise((resolve, reject) => {
94
+ // Ensure auth is fresh enough for codex exec by reading the file (codex
95
+ // itself will refresh via its internal logic on the next call).
96
+ getAuth();
97
+ const args = [
98
+ "exec",
99
+ "--json",
100
+ "--skip-git-repo-check",
101
+ "-m", model,
102
+ prompt,
103
+ ];
104
+ const child = spawn(codexBin, args, {
105
+ env: {
106
+ ...process.env,
107
+ CODEX_HOME,
108
+ NO_COLOR: "1",
109
+ TERM: "dumb",
110
+ },
111
+ stdio: ["ignore", "pipe", "pipe"],
112
+ });
113
+ let stdout = "";
114
+ let stderr = "";
115
+ let aborted = false;
116
+ const onAbort = () => {
117
+ aborted = true;
118
+ try { child.kill("SIGTERM"); } catch {}
119
+ };
120
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
121
+ child.stdout.on("data", (c) => (stdout += c.toString()));
122
+ child.stderr.on("data", (c) => (stderr += c.toString()));
123
+ child.on("error", (e) => reject(e));
124
+ child.on("close", (code) => {
125
+ if (signal) signal.removeEventListener("abort", onAbort);
126
+ if (aborted) return reject(new Error("aborted"));
127
+ if (code !== 0) {
128
+ return reject(
129
+ new Error(`codex exec exited ${code}: ${stderr.slice(0, 400)}`),
130
+ );
131
+ }
132
+ // Parse the last item.completed agent_message + usage.
133
+ let lastText = "";
134
+ let usage = null;
135
+ for (const line of stdout.split("\n")) {
136
+ const l = line.trim();
137
+ if (!l.startsWith("{")) continue;
138
+ let evt;
139
+ try { evt = JSON.parse(l); } catch { continue; }
140
+ if (evt.type === "item.completed" && evt.item?.type === "agent_message") {
141
+ lastText = evt.item.text ?? lastText;
142
+ }
143
+ if (evt.type === "turn.completed") {
144
+ usage = evt.usage ?? null;
145
+ }
146
+ }
147
+ resolve({ text: lastText, usage, stderr });
148
+ });
149
+ });
150
+ }
151
+
152
+ // ---- OpenAI shape helpers ----
153
+ function now() { return Math.floor(Date.now() / 1000); }
154
+ function genId(prefix) {
155
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
156
+ }
157
+ function toUsage(u) {
158
+ if (!u) return { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
159
+ const input = u.input_tokens ?? 0;
160
+ const output = u.output_tokens ?? 0;
161
+ return {
162
+ prompt_tokens: input,
163
+ completion_tokens: output,
164
+ total_tokens: input + output,
165
+ };
166
+ }
167
+ function renderTemplate(tmpl, vars) {
168
+ if (typeof tmpl === "string") return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "");
169
+ // Multimodal array: replace text parts with {{key}} expansion.
170
+ return tmpl
171
+ .map((part) =>
172
+ part?.type === "text"
173
+ ? { ...part, text: part.text.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "") }
174
+ : part,
175
+ );
176
+ }
177
+
178
+ // ---- HTTP server ----
179
+ const server = http.createServer(async (req, res) => {
180
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
181
+ try {
182
+ if (req.method === "GET" && url.pathname === "/healthz") {
183
+ return json(res, 200, { ok: true, port: PORT });
184
+ }
185
+ if (req.method === "GET" && url.pathname === "/v1/models") {
186
+ return json(res, 200, listModels());
187
+ }
188
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
189
+ const body = await readBody(req);
190
+ let payload;
191
+ try { payload = JSON.parse(body); } catch { return json(res, 400, { error: { message: "invalid JSON" } }); }
192
+ return handleChatCompletions(payload, req, res);
193
+ }
194
+ return json(res, 404, { error: { message: "not found", path: url.pathname } });
195
+ } catch (e) {
196
+ log("error", "handler failed", { err: String(e), stack: e?.stack });
197
+ return json(res, 500, { error: { message: String(e.message || e) } });
198
+ }
199
+ });
200
+
201
+ function readBody(req) {
202
+ return new Promise((resolve, reject) => {
203
+ const chunks = [];
204
+ req.on("data", (c) => chunks.push(c));
205
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
206
+ req.on("error", reject);
207
+ });
208
+ }
209
+ function json(res, status, obj) {
210
+ const body = JSON.stringify(obj);
211
+ res.writeHead(status, {
212
+ "content-type": "application/json",
213
+ "content-length": Buffer.byteLength(body),
214
+ "access-control-allow-origin": "*",
215
+ });
216
+ res.end(body);
217
+ }
218
+ function startSse(res, status = 200) {
219
+ res.writeHead(status, {
220
+ "content-type": "text/event-stream; charset=utf-8",
221
+ "cache-control": "no-cache",
222
+ "connection": "keep-alive",
223
+ "x-accel-buffering": "no",
224
+ "access-control-allow-origin": "*",
225
+ });
226
+ }
227
+ function sse(res, event, data) {
228
+ if (event) res.write(`event: ${event}\n`);
229
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
230
+ }
231
+
232
+ async function handleChatCompletions(payload, req, res) {
233
+ const {
234
+ model,
235
+ messages = [],
236
+ stream = false,
237
+ temperature,
238
+ top_p,
239
+ max_tokens,
240
+ } = payload || {};
241
+ if (!model) return json(res, 400, { error: { message: "model is required" } });
242
+ if (!Array.isArray(messages) || messages.length === 0) {
243
+ return json(res, 400, { error: { message: "messages[] required" } });
244
+ }
245
+
246
+ // Build prompt: concatenate system/user/assistant turns.
247
+ const parts = [];
248
+ let lastRole = null;
249
+ for (const m of messages) {
250
+ if (!m || !m.role) continue;
251
+ let text = "";
252
+ if (typeof m.content === "string") text = m.content;
253
+ else if (Array.isArray(m.content)) {
254
+ text = m.content
255
+ .filter((p) => p?.type === "text")
256
+ .map((p) => p.text || "")
257
+ .join("\n");
258
+ } else if (m.content) text = String(m.content);
259
+ if (!text && m.role !== lastRole) parts.push("");
260
+ if (text) {
261
+ const tag = m.role.toUpperCase();
262
+ parts.push(`${tag}: ${text}`);
263
+ }
264
+ lastRole = m.role;
265
+ }
266
+ parts.push("ASSISTANT:");
267
+ const prompt = parts.filter(Boolean).join("\n\n");
268
+
269
+ const ac = new AbortController();
270
+ req.on("close", () => ac.abort());
271
+
272
+ log("info", "chat.completions", { model, stream, msgs: messages.length });
273
+ let result;
274
+ try {
275
+ result = await runCodex({ model, prompt, signal: ac.signal });
276
+ } catch (e) {
277
+ return json(res, 502, { error: { message: String(e.message || e), type: "codex_error" } });
278
+ }
279
+
280
+ const id = genId("chatcmpl");
281
+ const created = now();
282
+ const usage = toUsage(result.usage);
283
+
284
+ if (stream) {
285
+ startSse(res);
286
+ // First chunk: role.
287
+ sse(res, null, {
288
+ id, object: "chat.completion.chunk", created,
289
+ model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
290
+ });
291
+ // Content chunk (codex exec doesn't stream per-token; emit as one).
292
+ if (result.text) {
293
+ sse(res, null, {
294
+ id, object: "chat.completion.chunk", created,
295
+ model,
296
+ choices: [{
297
+ index: 0,
298
+ delta: { content: result.text },
299
+ finish_reason: null,
300
+ }],
301
+ });
302
+ }
303
+ // Final chunk with finish_reason + usage.
304
+ sse(res, null, {
305
+ id, object: "chat.completion.chunk", created, model,
306
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
307
+ });
308
+ sse(res, null, {
309
+ id, object: "chat.completion.chunk", created, model,
310
+ choices: [],
311
+ usage,
312
+ });
313
+ res.write("data: [DONE]\n\n");
314
+ res.end();
315
+ return;
316
+ }
317
+
318
+ return json(res, 200, {
319
+ id, object: "chat.completion", created, model,
320
+ choices: [{
321
+ index: 0,
322
+ message: { role: "assistant", content: result.text },
323
+ finish_reason: "stop",
324
+ logprobs: null,
325
+ }],
326
+ usage,
327
+ });
328
+ }
329
+
330
+ server.listen(PORT, HOST, () => {
331
+ log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
332
+ });
333
+
334
+ process.on("SIGTERM", () => server.close(() => process.exit(0)));
335
+ process.on("SIGINT", () => server.close(() => process.exit(0)));
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ // sync-cache.mjs: merge the codex models cache into opencode.jsonc so the
3
+ // `provider.chatgpt.models` block stays in sync with the user's ChatGPT
4
+ // plan. Idempotent: prints "unchanged" if the file wouldn't change.
5
+ //
6
+ // Usage:
7
+ // node sync-cache.mjs [--config <path>] [--codex-home <path>] [--provider <name>] [--write|--no-write]
8
+ //
9
+ // Default config: ~/.config/opencode/opencode.jsonc
10
+ // Default codex home: ~/.codex
11
+ // Default provider name: chatgpt
12
+
13
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const args = process.argv.slice(2);
18
+ function flag(name, fallback) {
19
+ const i = args.indexOf(`--${name}`);
20
+ if (i >= 0 && i + 1 < args.length) return args[i + 1];
21
+ return fallback;
22
+ }
23
+ function hasFlag(name) { return args.includes(`--${name}`); }
24
+
25
+ const CONFIG_PATH = flag("config", join(homedir(), ".config", "opencode", "opencode.jsonc"));
26
+ const CODEX_HOME = flag("codex-home", join(homedir(), ".codex"));
27
+ const PROVIDER = flag("provider", "chatgpt");
28
+ const DO_WRITE = !hasFlag("no-write");
29
+ const CACHE_PATH = join(CODEX_HOME, "models_cache.json");
30
+
31
+ // ---- 1. Load models from codex cache ----
32
+ if (!existsSync(CACHE_PATH)) {
33
+ console.error(`sync-cache: no models_cache.json at ${CACHE_PATH}`);
34
+ process.exit(0); // not an error: codex hasn't been run yet
35
+ }
36
+
37
+ const cache = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
38
+ if (!Array.isArray(cache.models)) {
39
+ console.error("sync-cache: cache.models is not an array");
40
+ process.exit(0);
41
+ }
42
+
43
+ // Visibility gate: include models that are list-able. Some are hidden / review-only.
44
+ const VISIBLE = ["list", "default", null, undefined];
45
+ const slugs = cache.models
46
+ .filter((m) => VISIBLE.includes(m.visibility))
47
+ .filter((m) => m.supported_in_api !== false) // skip codex-auto-review etc.
48
+ .map((m) => m.slug)
49
+ .filter(Boolean);
50
+
51
+ // Stable order: priority first, then slug.
52
+ slugs.sort((a, b) => {
53
+ const ma = cache.models.find((x) => x.slug === a) || {};
54
+ const mb = cache.models.find((x) => x.slug === b) || {};
55
+ return (ma.priority ?? 999) - (mb.priority ?? 999) || a.localeCompare(b);
56
+ });
57
+
58
+ if (slugs.length === 0) {
59
+ console.error("sync-cache: no usable slugs in cache");
60
+ process.exit(0);
61
+ }
62
+
63
+ // ---- 2. Build the desired models block ----
64
+ const desired = {};
65
+ for (const slug of slugs) {
66
+ desired[slug] = {
67
+ name: `${slug} (Business)`,
68
+ limit: { context: 272000, output: 128000 },
69
+ modalities: { input: ["text", "image"], output: ["text"] },
70
+ };
71
+ }
72
+
73
+ // ---- 3. Compare with current file ----
74
+ if (!existsSync(CONFIG_PATH)) {
75
+ console.error(`sync-cache: no config at ${CONFIG_PATH}`);
76
+ process.exit(1);
77
+ }
78
+ const raw = readFileSync(CONFIG_PATH, "utf8");
79
+
80
+ // Detect JSONC by stripping comments + trailing commas before JSON.parse.
81
+ // We don't rewrite the file as JSON; we only do textual surgery for the
82
+ // provider.<name>.models block. Keeps comments and surrounding formatting.
83
+ function stripJsonc(src) {
84
+ let out = "";
85
+ let i = 0;
86
+ let inStr = false;
87
+ let quote = "";
88
+ while (i < src.length) {
89
+ const c = src[i];
90
+ if (inStr) {
91
+ out += c;
92
+ if (c === "\\" && i + 1 < src.length) { out += src[i + 1]; i += 2; continue; }
93
+ if (c === quote) inStr = false;
94
+ i++;
95
+ continue;
96
+ }
97
+ if (c === '"' || c === "'") { inStr = true; quote = c; out += c; i++; continue; }
98
+ if (c === "/" && src[i + 1] === "/") {
99
+ while (i < src.length && src[i] !== "\n") i++;
100
+ continue;
101
+ }
102
+ if (c === "/" && src[i + 1] === "*") {
103
+ i += 2;
104
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
105
+ i += 2;
106
+ continue;
107
+ }
108
+ out += c;
109
+ i++;
110
+ }
111
+ return out.replace(/,\s*([}\]])/g, "$1");
112
+ }
113
+
114
+ const pure = stripJsonc(raw);
115
+ const cfg = JSON.parse(pure);
116
+
117
+ const current = cfg.provider?.[PROVIDER] || null;
118
+ const currentModels = current?.models || {};
119
+ const currentSlugs = Object.keys(currentModels);
120
+
121
+ function sameSet(a, b) {
122
+ if (a.length !== b.length) return false;
123
+ const sb = new Set(b);
124
+ for (const x of a) if (!sb.has(x)) return false;
125
+ return true;
126
+ }
127
+
128
+ if (sameSet(currentSlugs, slugs)) {
129
+ console.log(JSON.stringify({ status: "unchanged", count: slugs.length, path: CONFIG_PATH }));
130
+ process.exit(0);
131
+ }
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
142
+ //
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);
155
+
156
+ function renderModelsBlock(models, baseIndent) {
157
+ const lines = ["{"];
158
+ const keys = Object.keys(models);
159
+ for (let i = 0; i < keys.length; i++) {
160
+ const k = keys[i];
161
+ const m = models[k];
162
+ const last = i === keys.length - 1;
163
+ lines.push(`${baseIndent}"${k}": {`);
164
+ lines.push(`${baseIndent} "name": ${JSON.stringify(m.name)},`);
165
+ lines.push(`${baseIndent} "limit": { "context": ${m.limit.context}, "output": ${m.limit.output} },`);
166
+ lines.push(`${baseIndent} "modalities": { "input": ${JSON.stringify(m.modalities.input)}, "output": ${JSON.stringify(m.modalities.output)} }`);
167
+ lines.push(`${baseIndent}}${last ? "" : ","}`);
168
+ }
169
+ lines.push(`${baseIndent.slice(0, baseIndent.length - (baseIndent.length > 2 ? 2 : baseIndent.length))}}`);
170
+ return lines.join("\n");
171
+ }
172
+
173
+ function replaceProvider(rawText, providerName, newProviderObj) {
174
+ // Find the provider's key at whatever indent level it's at.
175
+ // We capture the leading indent so the replacement is inserted with the
176
+ // matching outer indent. Inner indents are derived as outerIndent + step.
177
+ const re = new RegExp(`(^|\\n)([ \\t]*)"${providerName}"\\s*:\\s*\\{`);
178
+ const m = re.exec(rawText);
179
+ if (!m) return null;
180
+ const outerIndent = m[2];
181
+ const step = " "; // assume 2-space step; sample below if we find better evidence
182
+
183
+ // Walk braces from the opening `{` of this provider object.
184
+ const startBrace = m.index + m[0].length - 1; // points to '{'
185
+ let depth = 0;
186
+ let i = startBrace;
187
+ let inStr = false, quote = "";
188
+ while (i < rawText.length) {
189
+ const c = rawText[i];
190
+ if (inStr) {
191
+ if (c === "\\") { i += 2; continue; }
192
+ if (c === quote) inStr = false;
193
+ i++; continue;
194
+ }
195
+ if (c === '"' || c === "'") { inStr = true; quote = c; i++; continue; }
196
+ // skip line comments loosely (we don't try to be perfect here, JSONC comments
197
+ // inside strings are rare and we only insert at this exact scope).
198
+ if (c === "/" && rawText[i + 1] === "/") {
199
+ while (i < rawText.length && rawText[i] !== "\n") i++;
200
+ continue;
201
+ }
202
+ if (c === "{") depth++;
203
+ else if (c === "}") { depth--; if (depth === 0) { i++; break; } }
204
+ i++;
205
+ }
206
+ const endBrace = i - 1;
207
+ // Now we own rawText.slice(startBrace, i) — the provider object body.
208
+
209
+ // Build a fresh provider object that preserves baseURL/options but replaces models.
210
+ const preserved = { ...newProviderObj };
211
+ preserved.models = newProviderObj.models;
212
+
213
+ // Render the new provider block with stable JSON (no comments inside)
214
+ // using indent sized as outerIndent + step. We rebuild with a custom dumper
215
+ // 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
+ const replacement = dump(preserved, outerIndent);
237
+
238
+ // Make it match the file's line endings style by normalizing.
239
+ const fileLE = rawText.includes("\r\n") ? "\r\n" : "\n";
240
+ const replacementLE = replacement.replace(/\n/g, fileLE);
241
+
242
+ return rawText.slice(0, startBrace) + replacementLE + rawText.slice(endBrace + 1);
243
+ }
244
+
245
+ const nextCfg = JSON.parse(JSON.stringify(cfg));
246
+ 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;
253
+ }
254
+
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);
259
+ }
260
+
261
+ // Validate the new file as JSONC-roundtrip.
262
+ try {
263
+ JSON.parse(stripJsonc(newRaw));
264
+ } catch (e) {
265
+ console.error("sync-cache: generated file is invalid JSONC:", e.message);
266
+ process.exit(2);
267
+ }
268
+
269
+ if (!DO_WRITE) {
270
+ console.log(JSON.stringify({ status: "dry-run", added: slugs.length, removed: currentSlugs.length }));
271
+ process.exit(0);
272
+ }
273
+
274
+ writeFileSync(CONFIG_PATH, newRaw, "utf8");
275
+ console.log(JSON.stringify({
276
+ status: "written",
277
+ path: CONFIG_PATH,
278
+ before: currentSlugs.length,
279
+ after: slugs.length,
280
+ added: slugs.filter((s) => !currentSlugs.includes(s)),
281
+ removed: currentSlugs.filter((s) => !slugs.includes(s)),
282
+ }));
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@jaralabs/codex-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
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
+ "type": "module",
7
+ "main": "lib/server.mjs",
8
+ "exports": {
9
+ ".": "./lib/server.mjs",
10
+ "./sync": "./lib/sync-cache.mjs",
11
+ "./plugin": "./lib/plugin.ts"
12
+ },
13
+ "bin": {
14
+ "codex-bridge": "bin/codex-bridge"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org/",
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "scripts": {
30
+ "start": "bin/codex-bridge start",
31
+ "stop": "bin/codex-bridge stop",
32
+ "status": "bin/codex-bridge status",
33
+ "sync": "bin/codex-bridge sync",
34
+ "logs": "bin/codex-bridge logs",
35
+ "test": "node test/test.mjs"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/jaralabs/codex-bridge.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/jaralabs/codex-bridge/issues"
43
+ },
44
+ "keywords": [
45
+ "opencode",
46
+ "chatgpt",
47
+ "codex",
48
+ "openai-compatible",
49
+ "bridge",
50
+ "subscription",
51
+ "coding-agent"
52
+ ],
53
+ "author": "Andrew Jaramillo <javamillog1999@outlook.com>",
54
+ "license": "MIT"
55
+ }