@jaralabs/codex-bridge 0.1.0 → 0.3.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/plugin.ts +122 -23
- package/lib/server.mjs +423 -186
- package/lib/sync-cache.mjs +159 -51
- package/lib/tools-registry.mjs +57 -0
- package/package.json +8 -3
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/plugin.ts
CHANGED
|
@@ -1,28 +1,63 @@
|
|
|
1
|
-
// codex-bridge OpenCode plugin
|
|
1
|
+
// codex-bridge OpenCode plugin.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Goals:
|
|
4
|
+
// 1. Register the chatgpt provider pointing at the local bridge
|
|
5
|
+
// (http://127.0.0.1:11434/v1), pulling the live model list from
|
|
6
|
+
// ~/.codex/models_cache.json.
|
|
7
|
+
// 2. Register MCP servers (Doop, codegraph, etc.) so they show up in
|
|
8
|
+
// OpenCode's tool list when the user picks a ChatGPT model.
|
|
9
|
+
// 3. Inject a permission policy (V2 schema) that mirrors ChatGPT Plus:
|
|
10
|
+
// every MCP tool is `ask`, read-only built-in tools are `allow`.
|
|
5
11
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
12
|
+
// Install: copy this file to ~/.config/opencode/plugins/codex-bridge.ts
|
|
13
|
+
// (or symlink). The bridge does NOT auto-install; that is the user's call.
|
|
15
14
|
|
|
16
|
-
import { spawnSync } from "node:child_process"
|
|
17
15
|
import { existsSync, readFileSync } from "node:fs"
|
|
18
16
|
import { join } from "node:path"
|
|
19
17
|
|
|
20
18
|
const CODEX_HOME = process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex")
|
|
21
19
|
const CACHE = join(CODEX_HOME, "models_cache.json")
|
|
22
20
|
const PROVIDER_ID = "chatgpt"
|
|
21
|
+
const BRIDGE_BASE = process.env.CODEX_BRIDGE_BASE || "http://127.0.0.1:11434/v1"
|
|
23
22
|
|
|
24
23
|
const VISIBLE = new Set(["list", "default", null, undefined])
|
|
25
24
|
|
|
25
|
+
// MCP servers we always want available, even if the user has not added
|
|
26
|
+
// them via `opencode mcp add`. The user can disable any of these by
|
|
27
|
+
// setting the matching env var to "1":
|
|
28
|
+
// CODEX_BRIDGE_DISABLE_DOOP=1
|
|
29
|
+
// CODEX_BRIDGE_DISABLE_CODEGRAPH=1
|
|
30
|
+
// CODEX_BRIDGE_DISABLE_CODEGRAPH_FE=1
|
|
31
|
+
// CODEX_BRIDGE_DISABLE_ANGECOM=1
|
|
32
|
+
function defaultMcpServers(): Record<string, any> {
|
|
33
|
+
const out: Record<string, any> = {}
|
|
34
|
+
if (process.env.CODEX_BRIDGE_DISABLE_DOOP !== "1") {
|
|
35
|
+
const cmd = process.env.DOOP_MCP_COMMAND || "npx"
|
|
36
|
+
const args = (process.env.DOOP_MCP_ARGS || "-y @arqora/doop-mcp").split(" ")
|
|
37
|
+
out["doop"] = { type: "local", command: [cmd, ...args] }
|
|
38
|
+
}
|
|
39
|
+
if (process.env.CODEX_BRIDGE_DISABLE_CODEGRAPH !== "1") {
|
|
40
|
+
out["codegraph"] = { type: "local", command: ["codegraph", "mcp"] }
|
|
41
|
+
}
|
|
42
|
+
if (process.env.CODEX_BRIDGE_DISABLE_CODEGRAPH_FE !== "1") {
|
|
43
|
+
out["codegraph-fe"] = { type: "local", command: ["codegraph-fe", "mcp"] }
|
|
44
|
+
}
|
|
45
|
+
if (process.env.CODEX_BRIDGE_DISABLE_ANGECOM !== "1") {
|
|
46
|
+
// The angecom MCP lives outside this repo; only register it if the
|
|
47
|
+
// user has it on disk. This keeps the plugin usable from any cwd.
|
|
48
|
+
const home = process.env.HOME || "/tmp"
|
|
49
|
+
const candidate = join(home, "workspace/angecom/angecom-mcp/dist/index.js")
|
|
50
|
+
if (existsSync(candidate)) {
|
|
51
|
+
out["angecom"] = {
|
|
52
|
+
type: "local",
|
|
53
|
+
command: ["node", candidate],
|
|
54
|
+
cwd: join(home, "workspace/angecom/angecom-mcp"),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
function readCache(): string[] {
|
|
27
62
|
if (!existsSync(CACHE)) return []
|
|
28
63
|
try {
|
|
@@ -63,33 +98,97 @@ function mergeModels(provider: any, slugs: string[]): void {
|
|
|
63
98
|
if (!same) provider.models = desired
|
|
64
99
|
}
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
101
|
+
// V2 permission schema: an array of { action, resource, effect } rules.
|
|
102
|
+
// Order matters: the last matching rule wins, so we put broad allow
|
|
103
|
+
// rules first, then explicit ask rules for state-changing tools, and
|
|
104
|
+
// finally a global "ask" catch-all so anything not listed still prompts.
|
|
105
|
+
//
|
|
106
|
+
// Read-only tools are allow-listed (Plus UX: reads run silently).
|
|
107
|
+
// Anything that mutates state or hits the network prompts the user.
|
|
108
|
+
function basePermissionRules(): any[] {
|
|
109
|
+
return [
|
|
110
|
+
// --- Read-only allow rules (built-in) ---
|
|
111
|
+
{ action: "read", resource: "*", effect: "allow" },
|
|
112
|
+
{ action: "glob", resource: "*", effect: "allow" },
|
|
113
|
+
{ action: "grep", resource: "*", effect: "allow" },
|
|
114
|
+
{ action: "list_mcp_resources", resource: "*", effect: "allow" },
|
|
115
|
+
{ action: "read_mcp_resource", resource: "*", effect: "allow" },
|
|
116
|
+
|
|
117
|
+
// --- MCP read-only tools (Doop and codegraph both expose several
|
|
118
|
+
// of these; the <server>_<tool> action pattern matches them). ---
|
|
119
|
+
// We list them explicitly so the saved-approval flow can record
|
|
120
|
+
// each tool separately instead of one broad rule.
|
|
121
|
+
{ action: "doop_list_canvases", resource: "*", effect: "allow" },
|
|
122
|
+
{ action: "doop_get_guidelines", resource: "*", effect: "allow" },
|
|
123
|
+
{ action: "doop_get_reference", resource: "*", effect: "allow" },
|
|
124
|
+
{ action: "doop_get_comments", resource: "*", effect: "allow" },
|
|
125
|
+
{ action: "doop_get_frame", resource: "*", effect: "allow" },
|
|
126
|
+
{ action: "doop_get_frame_screenshot", resource: "*", effect: "allow" },
|
|
127
|
+
{ action: "doop_get_linear_issue", resource: "*", effect: "allow" },
|
|
128
|
+
{ action: "codegraph_codegraph_explore", resource: "*", effect: "allow" },
|
|
129
|
+
{ action: "codegraph-fe_codegraph_explore", resource: "*", effect: "allow" },
|
|
130
|
+
{ action: "angecom_confluence_list_folder", resource: "*", effect: "allow" },
|
|
131
|
+
{ action: "angecom_jira_get_issue", resource: "*", effect: "allow" },
|
|
132
|
+
{ action: "angecom_jira_get_transitions", resource: "*", effect: "allow" },
|
|
133
|
+
{ action: "angecom_jira_list_attachments", resource: "*", effect: "allow" },
|
|
134
|
+
{ action: "angecom_list_workspaces", resource: "*", effect: "allow" },
|
|
135
|
+
{ action: "angecom_list_mcp_resources", resource: "*", effect: "allow" },
|
|
136
|
+
{ action: "opencode_list_mcp_resources", resource: "*", effect: "allow" },
|
|
137
|
+
{ action: "opencode_read_mcp_resource", resource: "*", effect: "allow" },
|
|
138
|
+
|
|
139
|
+
// --- Anything state-changing defaults to ask (Plus behavior). ---
|
|
140
|
+
{ action: "*", resource: "*", effect: "ask" },
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mergePermissions(current: any): any[] {
|
|
145
|
+
const base = basePermissionRules()
|
|
146
|
+
if (!Array.isArray(current)) return base
|
|
147
|
+
// User-defined rules win. We keep the base array as a fallback and
|
|
148
|
+
// append the user rules, so the user's specific "allow" overrides
|
|
149
|
+
// our "ask" catch-all at the bottom.
|
|
150
|
+
return [...base, ...current]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const CodexBridgePlugin = {
|
|
154
|
+
id: "codex-bridge",
|
|
68
155
|
async setup(_ctx: unknown) {
|
|
69
156
|
return {
|
|
70
157
|
config: async (cfg: any) => {
|
|
71
|
-
|
|
72
|
-
if (slugs.length === 0) return
|
|
158
|
+
// --- Provider ---
|
|
73
159
|
if (!cfg.provider) cfg.provider = {}
|
|
74
160
|
if (!cfg.provider[PROVIDER_ID]) {
|
|
75
161
|
cfg.provider[PROVIDER_ID] = {
|
|
76
162
|
npm: "@ai-sdk/openai-compatible",
|
|
77
163
|
name: "ChatGPT Business (codex-bridge)",
|
|
78
|
-
options: { baseURL:
|
|
164
|
+
options: { baseURL: BRIDGE_BASE },
|
|
79
165
|
models: {},
|
|
80
166
|
}
|
|
81
167
|
}
|
|
82
|
-
|
|
168
|
+
const slugs = readCache()
|
|
169
|
+
if (slugs.length > 0) {
|
|
170
|
+
mergeModels(cfg.provider[PROVIDER_ID], slugs)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- MCP servers ---
|
|
174
|
+
if (!cfg.mcp) cfg.mcp = {}
|
|
175
|
+
if (!cfg.mcp.servers) cfg.mcp.servers = {}
|
|
176
|
+
const defaults = defaultMcpServers()
|
|
177
|
+
for (const [name, def] of Object.entries(defaults)) {
|
|
178
|
+
if (!cfg.mcp.servers[name]) cfg.mcp.servers[name] = def
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Permission policy (V2 schema) ---
|
|
182
|
+
cfg.permissions = mergePermissions(cfg.permissions)
|
|
83
183
|
},
|
|
84
184
|
event: async ({ event }: { event: { type?: string } }) => {
|
|
85
185
|
if (event?.type === "session.created") {
|
|
86
|
-
//
|
|
87
|
-
// kept up to date by lib/sync-cache.mjs when codex-bridge start runs.
|
|
186
|
+
// No-op: on-disk config is already kept fresh by lib/sync-cache.mjs.
|
|
88
187
|
}
|
|
89
188
|
},
|
|
90
189
|
}
|
|
91
190
|
},
|
|
92
191
|
}
|
|
93
192
|
|
|
94
|
-
export default
|
|
95
|
-
export {
|
|
193
|
+
export default CodexBridgePlugin
|
|
194
|
+
export { CodexBridgePlugin }
|
package/lib/server.mjs
CHANGED
|
@@ -1,178 +1,384 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// codex-bridge: OpenAI-compatible HTTP server that
|
|
3
|
-
// Business/Team/Plus
|
|
4
|
-
//
|
|
2
|
+
// codex-bridge: OpenAI-compatible HTTP server that talks to ChatGPT
|
|
3
|
+
// Business/Team/Plus via the OpenAI Responses API directly. We do NOT
|
|
4
|
+
// shell out to `codex exec` because the CLI rejects Plus/Business
|
|
5
|
+
// models when invoked with `-m <slug>` and the auth path is simpler to
|
|
6
|
+
// reproduce here.
|
|
7
|
+
//
|
|
8
|
+
// Endpoints:
|
|
5
9
|
// GET /v1/models -> { object:"list", data:[...] }
|
|
6
10
|
// POST /v1/chat/completions -> OpenAI-style streaming or JSON response
|
|
11
|
+
// GET /v1/tools -> list registered MCP tools
|
|
12
|
+
// POST /v1/tools -> register a list of tools
|
|
13
|
+
// DELETE /v1/tools -> clear registry
|
|
14
|
+
//
|
|
15
|
+
// Auth: reads ~/.codex/auth.json, refreshes access_token via the OAuth
|
|
16
|
+
// refresh_token grant at https://auth.openai.com/oauth/token when
|
|
17
|
+
// expired.
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
19
|
+
// Transport: POST https://chatgpt.com/backend-api/codex/responses with
|
|
20
|
+
// Bearer <access_token> and the OpenAI Responses API body shape. We
|
|
21
|
+
// translate Chat Completions <-> Responses for the OpenCode client.
|
|
11
22
|
|
|
12
23
|
import http from "node:http";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
24
|
+
import { readFileSync, existsSync, writeFileSync, appendFileSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import {
|
|
28
|
+
registerTool,
|
|
29
|
+
registerTools,
|
|
30
|
+
listTools,
|
|
31
|
+
clearTools,
|
|
32
|
+
renderToolsSystemBlock,
|
|
33
|
+
} from "./tools-registry.mjs";
|
|
17
34
|
|
|
18
35
|
const PORT = Number(process.env.PORT || 11434);
|
|
19
36
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
20
|
-
const CODEX_HOME =
|
|
21
|
-
process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
37
|
+
const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
22
38
|
const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
23
39
|
const MODELS_CACHE = join(CODEX_HOME, "models_cache.json");
|
|
40
|
+
const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
|
|
41
|
+
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
42
|
+
const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; // public client id used by codex CLI
|
|
43
|
+
const LOG_PATH = process.env.CODEX_BRIDGE_LOG || "/tmp/codex-bridge.log";
|
|
24
44
|
|
|
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
45
|
function log(level, msg, extra) {
|
|
40
|
-
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
|
|
41
|
-
|
|
46
|
+
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...(extra || {}) });
|
|
47
|
+
try { appendFileSync(LOG_PATH, line + "\n"); } catch {}
|
|
48
|
+
if (process.env.CODEX_BRIDGE_VERBOSE) {
|
|
49
|
+
process.stderr.write(line + "\n");
|
|
50
|
+
}
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
// ----
|
|
45
|
-
let
|
|
53
|
+
// ---- auth ----
|
|
54
|
+
let _authCache = null;
|
|
55
|
+
let _authLoadedAt = 0;
|
|
56
|
+
const AUTH_TTL_MS = 30_000; // re-read the file every 30s
|
|
57
|
+
|
|
46
58
|
function loadAuth() {
|
|
47
59
|
if (!existsSync(AUTH_PATH)) {
|
|
48
|
-
throw new Error(`
|
|
60
|
+
throw new Error(`auth.json not found at ${AUTH_PATH}; run \`codex login\` first`);
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
if (_authCache && Date.now() - _authLoadedAt < AUTH_TTL_MS) return _authCache;
|
|
63
|
+
const raw = readFileSync(AUTH_PATH, "utf8");
|
|
64
|
+
const parsed = JSON.parse(raw);
|
|
65
|
+
if (parsed.auth_mode !== "chatgpt") {
|
|
66
|
+
throw new Error(`auth.json auth_mode=${parsed.auth_mode}; only chatgpt supported`);
|
|
53
67
|
}
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
_authCache = parsed.tokens;
|
|
69
|
+
_authLoadedAt = Date.now();
|
|
70
|
+
return _authCache;
|
|
56
71
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
|
|
73
|
+
async function refreshAccessToken(refreshToken) {
|
|
74
|
+
const body = new URLSearchParams({
|
|
75
|
+
grant_type: "refresh_token",
|
|
76
|
+
refresh_token: refreshToken,
|
|
77
|
+
client_id: OAUTH_CLIENT_ID,
|
|
78
|
+
scope: "openid profile email offline-access",
|
|
79
|
+
});
|
|
80
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
83
|
+
body: body.toString(),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
throw new Error(`oauth refresh failed ${res.status}: ${text.slice(0, 300)}`);
|
|
88
|
+
}
|
|
89
|
+
const json = await res.json();
|
|
90
|
+
const updated = {
|
|
91
|
+
..._authCache,
|
|
92
|
+
access_token: json.access_token,
|
|
93
|
+
refresh_token: json.refresh_token || _authCache.refresh_token,
|
|
94
|
+
id_token: json.id_token || _authCache.id_token,
|
|
95
|
+
};
|
|
96
|
+
// Persist back to auth.json so subsequent runs pick it up.
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(AUTH_PATH, "utf8");
|
|
99
|
+
const file = JSON.parse(raw);
|
|
100
|
+
file.tokens = updated;
|
|
101
|
+
writeFileSync(AUTH_PATH, JSON.stringify(file, null, 2));
|
|
102
|
+
} catch (e) {
|
|
103
|
+
log("warn", "could not persist refreshed tokens", { err: String(e) });
|
|
104
|
+
}
|
|
105
|
+
_authCache = updated;
|
|
106
|
+
return updated;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getAuth() {
|
|
110
|
+
const a = loadAuth();
|
|
111
|
+
// We don't track expiry precisely; the access_token works until the API
|
|
112
|
+
// returns 401. Refresh preemptively if the token is missing.
|
|
113
|
+
if (!a.access_token && a.refresh_token) {
|
|
114
|
+
return refreshAccessToken(a.refresh_token);
|
|
115
|
+
}
|
|
116
|
+
return a;
|
|
60
117
|
}
|
|
61
|
-
// Token refresh is delegated to codex itself; we just rely on the CLI.
|
|
62
118
|
|
|
63
|
-
// ----
|
|
119
|
+
// ---- models ----
|
|
64
120
|
function listModels() {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
}
|
|
121
|
+
if (!existsSync(MODELS_CACHE)) {
|
|
122
|
+
return { object: "list", data: [] };
|
|
84
123
|
}
|
|
85
|
-
|
|
86
|
-
|
|
124
|
+
try {
|
|
125
|
+
const cache = JSON.parse(readFileSync(MODELS_CACHE, "utf8"));
|
|
126
|
+
if (!Array.isArray(cache.models)) return { object: "list", data: [] };
|
|
127
|
+
const data = cache.models
|
|
128
|
+
.filter((m) => ["list", "default", null, undefined].includes(m.visibility ?? null))
|
|
129
|
+
.filter((m) => m.supported_in_api !== false)
|
|
130
|
+
.map((m) => ({ id: m.slug, object: "model", created: 0, owned_by: "openai-chatgpt" }));
|
|
131
|
+
return { object: "list", data };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log("warn", "listModels failed", { err: String(e) });
|
|
134
|
+
return { object: "list", data: [] };
|
|
87
135
|
}
|
|
88
|
-
return { object: "list", data: slugs.map(slugId) };
|
|
89
136
|
}
|
|
90
137
|
|
|
91
|
-
// ----
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
// ---- OpenAI Chat Completions <-> Responses API translation ----
|
|
139
|
+
//
|
|
140
|
+
// Responses API uses a different shape:
|
|
141
|
+
// input: [{ type: "message", role, content: [{ type: "input_text", text }] }, ...]
|
|
142
|
+
// tools: [{ type: "function", name, description, parameters, strict: false }]
|
|
143
|
+
// output is a list of items:
|
|
144
|
+
// { type: "message", role: "assistant", content: [{ type: "output_text", text }] }
|
|
145
|
+
// { type: "function_call", call_id, name, arguments }
|
|
146
|
+
// To feed tool results back, send input items of type
|
|
147
|
+
// { type: "function_call_output", call_id, output }
|
|
148
|
+
//
|
|
149
|
+
// We translate from Chat Completions on the way in and back on the way out.
|
|
150
|
+
|
|
151
|
+
function chatToolsToResponsesTools(tools) {
|
|
152
|
+
if (!Array.isArray(tools) || tools.length === 0) return undefined;
|
|
153
|
+
return tools
|
|
154
|
+
.map((t) => {
|
|
155
|
+
const fn = t.function || t;
|
|
156
|
+
if (!fn?.name) return null;
|
|
157
|
+
return {
|
|
158
|
+
type: "function",
|
|
159
|
+
name: fn.name,
|
|
160
|
+
description: fn.description || "",
|
|
161
|
+
parameters: fn.parameters || { type: "object", properties: {} },
|
|
162
|
+
strict: false,
|
|
163
|
+
};
|
|
164
|
+
})
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function chatMessagesToResponsesInput(messages) {
|
|
169
|
+
// Chat Completions role types we care about: system, user, assistant, tool, developer.
|
|
170
|
+
// Responses: instructions (system), input (list of message + function_call + function_call_output).
|
|
171
|
+
let instructions = "";
|
|
172
|
+
const input = [];
|
|
173
|
+
for (const m of messages) {
|
|
174
|
+
if (!m || !m.role) continue;
|
|
175
|
+
const role = m.role;
|
|
176
|
+
if (role === "system" || role === "developer") {
|
|
177
|
+
const txt = textOf(m.content);
|
|
178
|
+
if (txt) instructions = (instructions ? instructions + "\n\n" : "") + txt;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (role === "user") {
|
|
182
|
+
const txt = textOf(m.content);
|
|
183
|
+
if (txt) input.push({ type: "message", role: "user", content: [{ type: "input_text", text: txt }] });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (role === "assistant") {
|
|
187
|
+
// If the assistant produced tool_calls, emit them as function_call items so
|
|
188
|
+
// the next function_call_output can match.
|
|
189
|
+
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
190
|
+
for (const tc of m.tool_calls) {
|
|
191
|
+
const name = tc.function?.name || tc.name;
|
|
192
|
+
const args = typeof tc.function?.arguments === "string"
|
|
193
|
+
? tc.function.arguments
|
|
194
|
+
: (typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {}));
|
|
195
|
+
input.push({ type: "function_call", call_id: tc.id, name, arguments: args });
|
|
145
196
|
}
|
|
146
197
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
198
|
+
const txt = textOf(m.content);
|
|
199
|
+
if (txt) {
|
|
200
|
+
input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: txt }] });
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (role === "tool") {
|
|
205
|
+
const callId = m.tool_call_id || m.call_id;
|
|
206
|
+
const out = textOf(m.content);
|
|
207
|
+
if (callId) {
|
|
208
|
+
input.push({ type: "function_call_output", call_id: callId, output: out });
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { instructions, input };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function textOf(content) {
|
|
217
|
+
if (typeof content === "string") return content;
|
|
218
|
+
if (Array.isArray(content)) {
|
|
219
|
+
return content
|
|
220
|
+
.map((p) => {
|
|
221
|
+
if (typeof p === "string") return p;
|
|
222
|
+
if (!p) return "";
|
|
223
|
+
if (p.type === "text" || p.type === "input_text" || p.type === "output_text") return p.text || "";
|
|
224
|
+
if (p.type === "image_url" && p.image_url?.url) return `[image: ${p.image_url.url.slice(0, 60)}]`;
|
|
225
|
+
return "";
|
|
226
|
+
})
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join("\n");
|
|
229
|
+
}
|
|
230
|
+
if (content && typeof content === "object") return JSON.stringify(content);
|
|
231
|
+
return "";
|
|
150
232
|
}
|
|
151
233
|
|
|
152
|
-
// ---- OpenAI shape helpers ----
|
|
153
234
|
function now() { return Math.floor(Date.now() / 1000); }
|
|
154
235
|
function genId(prefix) {
|
|
155
236
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
156
237
|
}
|
|
238
|
+
|
|
157
239
|
function toUsage(u) {
|
|
158
240
|
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
241
|
return {
|
|
162
|
-
prompt_tokens:
|
|
163
|
-
completion_tokens:
|
|
164
|
-
total_tokens:
|
|
242
|
+
prompt_tokens: u.input_tokens ?? 0,
|
|
243
|
+
completion_tokens: u.output_tokens ?? 0,
|
|
244
|
+
total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
|
|
165
245
|
};
|
|
166
246
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
247
|
+
|
|
248
|
+
// ---- HTTP client to ChatGPT ----
|
|
249
|
+
async function callResponses({ model, instructions, input, tools, signal }) {
|
|
250
|
+
const auth = await getAuth();
|
|
251
|
+
const url = `${CHATGPT_BASE}/responses`;
|
|
252
|
+
const body = {
|
|
253
|
+
model,
|
|
254
|
+
store: false,
|
|
255
|
+
stream: true,
|
|
256
|
+
instructions: instructions || undefined,
|
|
257
|
+
input,
|
|
258
|
+
tool_choice: "auto",
|
|
259
|
+
parallel_tool_calls: true,
|
|
260
|
+
prompt_cache_key: `codex-bridge-${Date.now()}`,
|
|
261
|
+
};
|
|
262
|
+
if (tools && tools.length > 0) body.tools = tools;
|
|
263
|
+
|
|
264
|
+
const headers = {
|
|
265
|
+
"authorization": `Bearer ${auth.access_token}`,
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
"accept": "text/event-stream",
|
|
268
|
+
"OpenAI-Beta": "responses=experimental",
|
|
269
|
+
"chatgpt-account-id": auth.account_id || "",
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
log("info", "responses.request", { model, instructionsLen: (instructions || "").length, inputItems: input.length, tools: (tools || []).length });
|
|
273
|
+
|
|
274
|
+
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
275
|
+
if (res.status === 401 && auth.refresh_token) {
|
|
276
|
+
log("info", "responses.401 refreshing token");
|
|
277
|
+
const refreshed = await refreshAccessToken(auth.refresh_token);
|
|
278
|
+
headers.authorization = `Bearer ${refreshed.access_token}`;
|
|
279
|
+
const retry = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
280
|
+
if (!retry.ok) {
|
|
281
|
+
const text = await retry.text();
|
|
282
|
+
throw new Error(`chatgpt ${retry.status}: ${text.slice(0, 400)}`);
|
|
283
|
+
}
|
|
284
|
+
return retry;
|
|
285
|
+
}
|
|
286
|
+
if (!res.ok) {
|
|
287
|
+
const text = await res.text();
|
|
288
|
+
throw new Error(`chatgpt ${res.status}: ${text.slice(0, 400)}`);
|
|
289
|
+
}
|
|
290
|
+
return res;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Parse SSE stream from Responses API. We accumulate:
|
|
294
|
+
// - text deltas -> final text
|
|
295
|
+
// - function_call items -> tool calls
|
|
296
|
+
// - usage at response.completed
|
|
297
|
+
// Returns { text, toolCalls, usage, raw } where toolCalls is OpenAI shape.
|
|
298
|
+
async function readSse(res) {
|
|
299
|
+
const reader = res.body.getReader();
|
|
300
|
+
const decoder = new TextDecoder("utf8");
|
|
301
|
+
let buf = "";
|
|
302
|
+
let text = "";
|
|
303
|
+
const toolCalls = new Map(); // call_id -> { id, name, arguments }
|
|
304
|
+
let usage = null;
|
|
305
|
+
let currentItem = null; // { type, id?, call_id?, name?, content_index?, delta? }
|
|
306
|
+
|
|
307
|
+
function ensureCall(id, name) {
|
|
308
|
+
if (!toolCalls.has(id)) toolCalls.set(id, { id, name: name || "", arguments: "" });
|
|
309
|
+
return toolCalls.get(id);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
while (true) {
|
|
313
|
+
const { value, done } = await reader.read();
|
|
314
|
+
if (done) break;
|
|
315
|
+
buf += decoder.decode(value, { stream: true });
|
|
316
|
+
let idx;
|
|
317
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
318
|
+
const block = buf.slice(0, idx);
|
|
319
|
+
buf = buf.slice(idx + 2);
|
|
320
|
+
// Parse "event:" and "data:" lines.
|
|
321
|
+
let event = "";
|
|
322
|
+
let data = "";
|
|
323
|
+
for (const line of block.split("\n")) {
|
|
324
|
+
if (line.startsWith("event: ")) event = line.slice(7).trim();
|
|
325
|
+
else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
|
|
326
|
+
}
|
|
327
|
+
if (!data) continue;
|
|
328
|
+
if (data === "[DONE]") continue;
|
|
329
|
+
let json;
|
|
330
|
+
try { json = JSON.parse(data); } catch { continue; }
|
|
331
|
+
const t = json.type || event;
|
|
332
|
+
switch (t) {
|
|
333
|
+
case "response.output_item.added": {
|
|
334
|
+
const item = json.item || {};
|
|
335
|
+
currentItem = { type: item.type, id: item.id, call_id: item.call_id, name: item.name, content_index: 0 };
|
|
336
|
+
if (item.type === "function_call") {
|
|
337
|
+
ensureCall(item.call_id, item.name);
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "response.function_call_arguments.delta": {
|
|
342
|
+
const id = json.call_id || currentItem?.call_id;
|
|
343
|
+
if (id) {
|
|
344
|
+
const call = ensureCall(id, currentItem?.name || json.name);
|
|
345
|
+
call.arguments += json.delta || "";
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "response.function_call_arguments.done": {
|
|
350
|
+
const id = json.call_id || currentItem?.call_id;
|
|
351
|
+
if (id && typeof json.arguments === "string") {
|
|
352
|
+
const call = ensureCall(id, currentItem?.name || json.name);
|
|
353
|
+
call.arguments = json.arguments;
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "response.output_text.delta": {
|
|
358
|
+
text += json.delta || "";
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "response.completed": {
|
|
362
|
+
const r = json.response || {};
|
|
363
|
+
if (r.usage) usage = r.usage;
|
|
364
|
+
// Synthesize text from output if we missed deltas.
|
|
365
|
+
if (!text && Array.isArray(r.output)) {
|
|
366
|
+
for (const item of r.output) {
|
|
367
|
+
if (item.type === "message") {
|
|
368
|
+
for (const part of item.content || []) {
|
|
369
|
+
if (part.type === "output_text" && part.text) text += part.text;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
default:
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { text, toolCalls: Array.from(toolCalls.values()), usage };
|
|
176
382
|
}
|
|
177
383
|
|
|
178
384
|
// ---- HTTP server ----
|
|
@@ -185,6 +391,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
185
391
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
186
392
|
return json(res, 200, listModels());
|
|
187
393
|
}
|
|
394
|
+
if (req.method === "GET" && url.pathname === "/v1/tools") {
|
|
395
|
+
return json(res, 200, { object: "list", data: listTools() });
|
|
396
|
+
}
|
|
397
|
+
if (req.method === "POST" && url.pathname === "/v1/tools") {
|
|
398
|
+
const body = await readBody(req);
|
|
399
|
+
let payload;
|
|
400
|
+
try { payload = JSON.parse(body); } catch { return json(res, 400, { error: { message: "invalid JSON" } }); }
|
|
401
|
+
if (Array.isArray(payload)) registerTools(payload);
|
|
402
|
+
else if (Array.isArray(payload?.tools)) registerTools(payload.tools);
|
|
403
|
+
else return json(res, 400, { error: { message: "expected array or { tools: [...] }" } });
|
|
404
|
+
return json(res, 200, { object: "list", data: listTools(), count: listTools().length });
|
|
405
|
+
}
|
|
406
|
+
if (req.method === "DELETE" && url.pathname === "/v1/tools") {
|
|
407
|
+
clearTools();
|
|
408
|
+
return json(res, 200, { object: "list", data: [], count: 0 });
|
|
409
|
+
}
|
|
188
410
|
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
189
411
|
const body = await readBody(req);
|
|
190
412
|
let payload;
|
|
@@ -237,98 +459,113 @@ async function handleChatCompletions(payload, req, res) {
|
|
|
237
459
|
temperature,
|
|
238
460
|
top_p,
|
|
239
461
|
max_tokens,
|
|
462
|
+
tools,
|
|
240
463
|
} = payload || {};
|
|
241
464
|
if (!model) return json(res, 400, { error: { message: "model is required" } });
|
|
242
465
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
243
466
|
return json(res, 400, { error: { message: "messages[] required" } });
|
|
244
467
|
}
|
|
245
468
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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}`);
|
|
469
|
+
const ac = new AbortController();
|
|
470
|
+
req.on("close", () => ac.abort());
|
|
471
|
+
|
|
472
|
+
// Build the Responses API input. If no `tools` came in, we still inject
|
|
473
|
+
// a Plus-style system block describing the registered tools so the model
|
|
474
|
+
// can call them.
|
|
475
|
+
const responsesTools = chatToolsToResponsesTools(tools);
|
|
476
|
+
let { instructions, input } = chatMessagesToResponsesInput(messages);
|
|
477
|
+
if (!(responsesTools && responsesTools.length > 0)) {
|
|
478
|
+
const block = renderToolsSystemBlock();
|
|
479
|
+
if (block) {
|
|
480
|
+
instructions = instructions ? `${instructions}\n\n${block}` : block;
|
|
263
481
|
}
|
|
264
|
-
lastRole = m.role;
|
|
265
482
|
}
|
|
266
|
-
parts.push("ASSISTANT:");
|
|
267
|
-
const prompt = parts.filter(Boolean).join("\n\n");
|
|
268
483
|
|
|
269
|
-
|
|
270
|
-
req.on("close", () => ac.abort());
|
|
484
|
+
log("info", "chat.completions", { model, stream, msgs: messages.length, toolCount: (tools || []).length });
|
|
271
485
|
|
|
272
|
-
|
|
486
|
+
// Single call. We do NOT loop in this implementation: when the model
|
|
487
|
+
// emits tool_calls, we return finish_reason=tool_calls to OpenCode.
|
|
488
|
+
// OpenCode will run the tools, send role:"tool" messages back, and the
|
|
489
|
+
// bridge makes a fresh request that includes those items.
|
|
273
490
|
let result;
|
|
274
491
|
try {
|
|
275
|
-
|
|
492
|
+
const upstream = await callResponses({
|
|
493
|
+
model,
|
|
494
|
+
instructions,
|
|
495
|
+
input,
|
|
496
|
+
tools: responsesTools,
|
|
497
|
+
signal: ac.signal,
|
|
498
|
+
});
|
|
499
|
+
result = await readSse(upstream);
|
|
276
500
|
} catch (e) {
|
|
277
|
-
return json(res, 502, { error: { message: String(e.message || e), type: "
|
|
501
|
+
return json(res, 502, { error: { message: String(e.message || e), type: "upstream_error" } });
|
|
278
502
|
}
|
|
279
503
|
|
|
280
504
|
const id = genId("chatcmpl");
|
|
281
505
|
const created = now();
|
|
282
506
|
const usage = toUsage(result.usage);
|
|
507
|
+
const hasToolCalls = result.toolCalls && result.toolCalls.length > 0;
|
|
508
|
+
const finishReason = hasToolCalls ? "tool_calls" : "stop";
|
|
509
|
+
const message = { role: "assistant", content: result.text || "" };
|
|
510
|
+
if (hasToolCalls) {
|
|
511
|
+
message.tool_calls = result.toolCalls.map((c, i) => ({
|
|
512
|
+
index: i,
|
|
513
|
+
id: c.id,
|
|
514
|
+
function: { name: c.name, arguments: c.arguments || "" },
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
283
517
|
|
|
284
518
|
if (stream) {
|
|
285
519
|
startSse(res);
|
|
286
|
-
// First chunk: role.
|
|
287
520
|
sse(res, null, {
|
|
288
|
-
id, object: "chat.completion.chunk", created,
|
|
289
|
-
|
|
521
|
+
id, object: "chat.completion.chunk", created, model,
|
|
522
|
+
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
290
523
|
});
|
|
291
|
-
// Content chunk (codex exec doesn't stream per-token; emit as one).
|
|
292
524
|
if (result.text) {
|
|
293
525
|
sse(res, null, {
|
|
294
|
-
id, object: "chat.completion.chunk", created,
|
|
295
|
-
|
|
296
|
-
choices: [{
|
|
297
|
-
index: 0,
|
|
298
|
-
delta: { content: result.text },
|
|
299
|
-
finish_reason: null,
|
|
300
|
-
}],
|
|
526
|
+
id, object: "chat.completion.chunk", created, model,
|
|
527
|
+
choices: [{ index: 0, delta: { content: result.text }, finish_reason: null }],
|
|
301
528
|
});
|
|
302
529
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
530
|
+
if (hasToolCalls) {
|
|
531
|
+
for (let i = 0; i < result.toolCalls.length; i++) {
|
|
532
|
+
const c = result.toolCalls[i];
|
|
533
|
+
sse(res, null, {
|
|
534
|
+
id, object: "chat.completion.chunk", created, model,
|
|
535
|
+
choices: [{
|
|
536
|
+
index: 0,
|
|
537
|
+
delta: {
|
|
538
|
+
tool_calls: [{
|
|
539
|
+
index: i,
|
|
540
|
+
id: c.id,
|
|
541
|
+
function: { name: c.name, arguments: c.arguments || "" },
|
|
542
|
+
}],
|
|
543
|
+
},
|
|
544
|
+
finish_reason: null,
|
|
545
|
+
}],
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
308
549
|
sse(res, null, {
|
|
309
550
|
id, object: "chat.completion.chunk", created, model,
|
|
310
|
-
choices: [],
|
|
311
|
-
usage,
|
|
551
|
+
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
|
|
312
552
|
});
|
|
553
|
+
sse(res, null, { id, object: "chat.completion.chunk", created, model, choices: [], usage });
|
|
554
|
+
// The "[DONE]" sentinel is the literal string, not a JSON-encoded one.
|
|
313
555
|
res.write("data: [DONE]\n\n");
|
|
314
|
-
res.end();
|
|
315
|
-
return;
|
|
556
|
+
return res.end();
|
|
316
557
|
}
|
|
317
558
|
|
|
318
559
|
return json(res, 200, {
|
|
319
560
|
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
|
-
}],
|
|
561
|
+
choices: [{ index: 0, message, finish_reason: finishReason, logprobs: null }],
|
|
326
562
|
usage,
|
|
327
563
|
});
|
|
328
564
|
}
|
|
329
565
|
|
|
330
566
|
server.listen(PORT, HOST, () => {
|
|
331
567
|
log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
|
|
568
|
+
process.stderr.write(`codex-bridge listening on http://${HOST}:${PORT}\n`);
|
|
332
569
|
});
|
|
333
570
|
|
|
334
571
|
process.on("SIGTERM", () => server.close(() => process.exit(0)));
|
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.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// tools-registry: tracks the list of MCP tools available to the bridge.
|
|
2
|
+
//
|
|
3
|
+
// In a normal ChatGPT Plus setup, the model "knows" about its tools via a
|
|
4
|
+
// system prompt that lists them. OpenCode does NOT inject the live MCP
|
|
5
|
+
// catalog into /v1/chat/completions (it only sends `tools` when the AI SDK
|
|
6
|
+
// explicitly enabled tool calling on the provider). To make the bridge feel
|
|
7
|
+
// like Plus, we maintain a small registry that the OpenCode plugin can
|
|
8
|
+
// populate via /v1/tools, and that handleChatCompletions injects into the
|
|
9
|
+
// system prompt when the model has no other source of tool awareness.
|
|
10
|
+
//
|
|
11
|
+
// The registry is in-memory and process-local. A real deployment would
|
|
12
|
+
// persist this to ~/.codex/tools_cache.json, but the bridge process is
|
|
13
|
+
// short-lived enough (one process per TUI session is overkill; the TUI
|
|
14
|
+
// restarts re-register via the plugin) that the in-memory map is fine.
|
|
15
|
+
|
|
16
|
+
const tools = new Map(); // name -> { server, description, parameters, serverType, command?, url? }
|
|
17
|
+
|
|
18
|
+
export function registerTool(name, meta) {
|
|
19
|
+
if (!name) return;
|
|
20
|
+
tools.set(name, {
|
|
21
|
+
server: meta.server || "unknown",
|
|
22
|
+
description: meta.description || "",
|
|
23
|
+
parameters: meta.parameters || { type: "object", properties: {} },
|
|
24
|
+
serverType: meta.serverType || "local",
|
|
25
|
+
command: meta.command,
|
|
26
|
+
url: meta.url,
|
|
27
|
+
registeredAt: Date.now(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerTools(list) {
|
|
32
|
+
if (!Array.isArray(list)) return;
|
|
33
|
+
for (const t of list) {
|
|
34
|
+
if (t?.name) registerTool(t.name, t);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listTools() {
|
|
39
|
+
return Array.from(tools.entries()).map(([name, meta]) => ({ name, ...meta }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearTools() {
|
|
43
|
+
tools.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Render the registry as a system-prompt block. We use a compact format
|
|
47
|
+
// the model can parse: each tool on its own line with a JSON schema.
|
|
48
|
+
export function renderToolsSystemBlock() {
|
|
49
|
+
if (tools.size === 0) return "";
|
|
50
|
+
const lines = ["You have access to the following tools. To call one, emit a tool_call with the matching name and JSON arguments."];
|
|
51
|
+
for (const [name, meta] of tools.entries()) {
|
|
52
|
+
const params = JSON.stringify(meta.parameters || { type: "object" });
|
|
53
|
+
const desc = meta.description ? ` — ${meta.description}` : "";
|
|
54
|
+
lines.push(`- ${name}${desc} args=${params}`);
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jaralabs/codex-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"status": "bin/codex-bridge status",
|
|
33
33
|
"sync": "bin/codex-bridge sync",
|
|
34
34
|
"logs": "bin/codex-bridge logs",
|
|
35
|
-
"test": "node test/test.mjs"
|
|
35
|
+
"test": "node test/test.mjs",
|
|
36
|
+
"test:tools": "node test/test-tools.mjs"
|
|
36
37
|
},
|
|
37
38
|
"repository": {
|
|
38
39
|
"type": "git",
|
|
@@ -51,5 +52,9 @@
|
|
|
51
52
|
"coding-agent"
|
|
52
53
|
],
|
|
53
54
|
"author": "Andrew Jaramillo <javamillog1999@outlook.com>",
|
|
54
|
-
"license": "MIT"
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^26.6.5",
|
|
58
|
+
"typescript": "^7.0.2"
|
|
59
|
+
}
|
|
55
60
|
}
|