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