@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 +21 -0
- package/README.md +98 -2
- package/bin/codex-bridge +193 -0
- package/lib/plugin.ts +95 -0
- package/lib/server.mjs +335 -0
- package/lib/sync-cache.mjs +282 -0
- package/package.json +53 -4
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
|
-
#
|
|
1
|
+
# @jaralabs/codex-bridge
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/bin/codex-bridge
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|