@jaralabs/codex-bridge 0.2.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/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,190 +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 (JSONL to stderr AND to LOG_FILE if set) ----
39
- const LOG_FILE = process.env.CODEX_BRIDGE_LOG || "";
40
- function writeLog(line) {
41
- try { process.stderr.write(line + "\n"); } catch {}
42
- if (LOG_FILE) {
43
- try {
44
- appendFileSync(LOG_FILE, line + "\n");
45
- } catch {}
46
- }
47
- }
48
45
  function log(level, msg, extra) {
49
- const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
50
- writeLog(line);
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
+ }
51
51
  }
52
52
 
53
- import { appendFileSync } from "node:fs";
54
- process.on("SIGPIPE", () => {});
53
+ // ---- auth ----
54
+ let _authCache = null;
55
+ let _authLoadedAt = 0;
56
+ const AUTH_TTL_MS = 30_000; // re-read the file every 30s
55
57
 
56
- // ---- Auth handling ----
57
- let authCache = null;
58
58
  function loadAuth() {
59
59
  if (!existsSync(AUTH_PATH)) {
60
- 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`);
61
61
  }
62
- const raw = JSON.parse(readFileSync(AUTH_PATH, "utf8"));
63
- if (raw.auth_mode !== "chatgpt") {
64
- 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`);
65
67
  }
66
- if (!raw.tokens?.access_token) throw new Error("codex-bridge: missing tokens.");
67
- return raw;
68
+ _authCache = parsed.tokens;
69
+ _authLoadedAt = Date.now();
70
+ return _authCache;
68
71
  }
69
- function getAuth() {
70
- if (!authCache) authCache = loadAuth();
71
- 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;
72
117
  }
73
- // Token refresh is delegated to codex itself; we just rely on the CLI.
74
118
 
75
- // ---- Models listing ----
119
+ // ---- models ----
76
120
  function listModels() {
77
- const slugId = (s) => ({
78
- id: s,
79
- object: "model",
80
- created: 0,
81
- owned_by: "openai-chatgpt",
82
- });
83
- let slugs = [];
84
- if (existsSync(MODELS_CACHE)) {
85
- try {
86
- const cache = JSON.parse(readFileSync(MODELS_CACHE, "utf8"));
87
- if (Array.isArray(cache.models)) {
88
- slugs = cache.models
89
- .filter((m) => m.visibility === "list" || m.visibility == null)
90
- .map((m) => m.slug)
91
- .filter(Boolean);
92
- }
93
- } catch (e) {
94
- log("warn", "models_cache.json parse failed", { err: String(e) });
95
- }
121
+ if (!existsSync(MODELS_CACHE)) {
122
+ return { object: "list", data: [] };
96
123
  }
97
- if (slugs.length === 0) {
98
- 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: [] };
99
135
  }
100
- return { object: "list", data: slugs.map(slugId) };
101
136
  }
102
137
 
103
- // ---- codex exec runner ----
104
- function runCodex({ model, prompt, signal }) {
105
- return new Promise((resolve, reject) => {
106
- // Ensure auth is fresh enough for codex exec by reading the file (codex
107
- // itself will refresh via its internal logic on the next call).
108
- getAuth();
109
- const args = [
110
- "exec",
111
- "--json",
112
- "--skip-git-repo-check",
113
- "-m", model,
114
- prompt,
115
- ];
116
- const child = spawn(codexBin, args, {
117
- env: {
118
- ...process.env,
119
- CODEX_HOME,
120
- NO_COLOR: "1",
121
- TERM: "dumb",
122
- },
123
- stdio: ["ignore", "pipe", "pipe"],
124
- });
125
- let stdout = "";
126
- let stderr = "";
127
- let aborted = false;
128
- const onAbort = () => {
129
- aborted = true;
130
- try { child.kill("SIGTERM"); } catch {}
131
- };
132
- if (signal) signal.addEventListener("abort", onAbort, { once: true });
133
- child.stdout.on("data", (c) => (stdout += c.toString()));
134
- child.stderr.on("data", (c) => (stderr += c.toString()));
135
- child.on("error", (e) => reject(e));
136
- child.on("close", (code) => {
137
- if (signal) signal.removeEventListener("abort", onAbort);
138
- if (aborted) return reject(new Error("aborted"));
139
- if (code !== 0) {
140
- return reject(
141
- new Error(`codex exec exited ${code}: ${stderr.slice(0, 400)}`),
142
- );
143
- }
144
- // Parse the last item.completed agent_message + usage.
145
- let lastText = "";
146
- let usage = null;
147
- for (const line of stdout.split("\n")) {
148
- const l = line.trim();
149
- if (!l.startsWith("{")) continue;
150
- let evt;
151
- try { evt = JSON.parse(l); } catch { continue; }
152
- if (evt.type === "item.completed" && evt.item?.type === "agent_message") {
153
- lastText = evt.item.text ?? lastText;
154
- }
155
- if (evt.type === "turn.completed") {
156
- 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 });
157
196
  }
158
197
  }
159
- resolve({ text: lastText, usage, stderr });
160
- });
161
- });
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 "";
162
232
  }
163
233
 
164
- // ---- OpenAI shape helpers ----
165
234
  function now() { return Math.floor(Date.now() / 1000); }
166
235
  function genId(prefix) {
167
236
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
168
237
  }
238
+
169
239
  function toUsage(u) {
170
240
  if (!u) return { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
171
- const input = u.input_tokens ?? 0;
172
- const output = u.output_tokens ?? 0;
173
241
  return {
174
- prompt_tokens: input,
175
- completion_tokens: output,
176
- 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),
177
245
  };
178
246
  }
179
- function renderTemplate(tmpl, vars) {
180
- if (typeof tmpl === "string") return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "");
181
- // Multimodal array: replace text parts with {{key}} expansion.
182
- return tmpl
183
- .map((part) =>
184
- part?.type === "text"
185
- ? { ...part, text: part.text.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "") }
186
- : part,
187
- );
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 };
188
382
  }
189
383
 
190
384
  // ---- HTTP server ----
@@ -197,6 +391,22 @@ const server = http.createServer(async (req, res) => {
197
391
  if (req.method === "GET" && url.pathname === "/v1/models") {
198
392
  return json(res, 200, listModels());
199
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
+ }
200
410
  if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
201
411
  const body = await readBody(req);
202
412
  let payload;
@@ -249,98 +459,113 @@ async function handleChatCompletions(payload, req, res) {
249
459
  temperature,
250
460
  top_p,
251
461
  max_tokens,
462
+ tools,
252
463
  } = payload || {};
253
464
  if (!model) return json(res, 400, { error: { message: "model is required" } });
254
465
  if (!Array.isArray(messages) || messages.length === 0) {
255
466
  return json(res, 400, { error: { message: "messages[] required" } });
256
467
  }
257
468
 
258
- // Build prompt: concatenate system/user/assistant turns.
259
- const parts = [];
260
- let lastRole = null;
261
- for (const m of messages) {
262
- if (!m || !m.role) continue;
263
- let text = "";
264
- if (typeof m.content === "string") text = m.content;
265
- else if (Array.isArray(m.content)) {
266
- text = m.content
267
- .filter((p) => p?.type === "text")
268
- .map((p) => p.text || "")
269
- .join("\n");
270
- } else if (m.content) text = String(m.content);
271
- if (!text && m.role !== lastRole) parts.push("");
272
- if (text) {
273
- const tag = m.role.toUpperCase();
274
- 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;
275
481
  }
276
- lastRole = m.role;
277
482
  }
278
- parts.push("ASSISTANT:");
279
- const prompt = parts.filter(Boolean).join("\n\n");
280
483
 
281
- const ac = new AbortController();
282
- req.on("close", () => ac.abort());
484
+ log("info", "chat.completions", { model, stream, msgs: messages.length, toolCount: (tools || []).length });
283
485
 
284
- 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.
285
490
  let result;
286
491
  try {
287
- 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);
288
500
  } catch (e) {
289
- 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" } });
290
502
  }
291
503
 
292
504
  const id = genId("chatcmpl");
293
505
  const created = now();
294
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
+ }
295
517
 
296
518
  if (stream) {
297
519
  startSse(res);
298
- // First chunk: role.
299
520
  sse(res, null, {
300
- id, object: "chat.completion.chunk", created,
301
- 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 }],
302
523
  });
303
- // Content chunk (codex exec doesn't stream per-token; emit as one).
304
524
  if (result.text) {
305
525
  sse(res, null, {
306
- id, object: "chat.completion.chunk", created,
307
- model,
308
- choices: [{
309
- index: 0,
310
- delta: { content: result.text },
311
- finish_reason: null,
312
- }],
526
+ id, object: "chat.completion.chunk", created, model,
527
+ choices: [{ index: 0, delta: { content: result.text }, finish_reason: null }],
313
528
  });
314
529
  }
315
- // Final chunk with finish_reason + usage.
316
- sse(res, null, {
317
- id, object: "chat.completion.chunk", created, model,
318
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
319
- });
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
+ }
320
549
  sse(res, null, {
321
550
  id, object: "chat.completion.chunk", created, model,
322
- choices: [],
323
- usage,
551
+ choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
324
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.
325
555
  res.write("data: [DONE]\n\n");
326
- res.end();
327
- return;
556
+ return res.end();
328
557
  }
329
558
 
330
559
  return json(res, 200, {
331
560
  id, object: "chat.completion", created, model,
332
- choices: [{
333
- index: 0,
334
- message: { role: "assistant", content: result.text },
335
- finish_reason: "stop",
336
- logprobs: null,
337
- }],
561
+ choices: [{ index: 0, message, finish_reason: finishReason, logprobs: null }],
338
562
  usage,
339
563
  });
340
564
  }
341
565
 
342
566
  server.listen(PORT, HOST, () => {
343
567
  log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
568
+ process.stderr.write(`codex-bridge listening on http://${HOST}:${PORT}\n`);
344
569
  });
345
570
 
346
571
  process.on("SIGTERM", () => server.close(() => process.exit(0)));
@@ -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.2.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
  }