@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 +122 -23
- package/lib/server.mjs +422 -197
- package/lib/tools-registry.mjs +57 -0
- package/package.json +8 -3
package/lib/plugin.ts
CHANGED
|
@@ -1,28 +1,63 @@
|
|
|
1
|
-
// codex-bridge OpenCode plugin
|
|
1
|
+
// codex-bridge OpenCode plugin.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Goals:
|
|
4
|
+
// 1. Register the chatgpt provider pointing at the local bridge
|
|
5
|
+
// (http://127.0.0.1:11434/v1), pulling the live model list from
|
|
6
|
+
// ~/.codex/models_cache.json.
|
|
7
|
+
// 2. Register MCP servers (Doop, codegraph, etc.) so they show up in
|
|
8
|
+
// OpenCode's tool list when the user picks a ChatGPT model.
|
|
9
|
+
// 3. Inject a permission policy (V2 schema) that mirrors ChatGPT Plus:
|
|
10
|
+
// every MCP tool is `ask`, read-only built-in tools are `allow`.
|
|
5
11
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// long-lived TUI server, opencode does reload, and the plugin on `session.created`
|
|
9
|
-
// is purely belt-and-suspenders.
|
|
10
|
-
//
|
|
11
|
-
// The plugin reads ~/.codex/models_cache.json and writes the resulting model list
|
|
12
|
-
// into cfg.provider.chatgpt.models. On TUI restart the change will already be
|
|
13
|
-
// present in opencode.jsonc thanks to lib/sync-cache.mjs running from
|
|
14
|
-
// `codex-bridge start`, so this hook is mostly a no-op.
|
|
12
|
+
// Install: copy this file to ~/.config/opencode/plugins/codex-bridge.ts
|
|
13
|
+
// (or symlink). The bridge does NOT auto-install; that is the user's call.
|
|
15
14
|
|
|
16
|
-
import { spawnSync } from "node:child_process"
|
|
17
15
|
import { existsSync, readFileSync } from "node:fs"
|
|
18
16
|
import { join } from "node:path"
|
|
19
17
|
|
|
20
18
|
const CODEX_HOME = process.env.CODEX_HOME || join(process.env.HOME || "/tmp", ".codex")
|
|
21
19
|
const CACHE = join(CODEX_HOME, "models_cache.json")
|
|
22
20
|
const PROVIDER_ID = "chatgpt"
|
|
21
|
+
const BRIDGE_BASE = process.env.CODEX_BRIDGE_BASE || "http://127.0.0.1:11434/v1"
|
|
23
22
|
|
|
24
23
|
const VISIBLE = new Set(["list", "default", null, undefined])
|
|
25
24
|
|
|
25
|
+
// MCP servers we always want available, even if the user has not added
|
|
26
|
+
// them via `opencode mcp add`. The user can disable any of these by
|
|
27
|
+
// setting the matching env var to "1":
|
|
28
|
+
// CODEX_BRIDGE_DISABLE_DOOP=1
|
|
29
|
+
// CODEX_BRIDGE_DISABLE_CODEGRAPH=1
|
|
30
|
+
// CODEX_BRIDGE_DISABLE_CODEGRAPH_FE=1
|
|
31
|
+
// CODEX_BRIDGE_DISABLE_ANGECOM=1
|
|
32
|
+
function defaultMcpServers(): Record<string, any> {
|
|
33
|
+
const out: Record<string, any> = {}
|
|
34
|
+
if (process.env.CODEX_BRIDGE_DISABLE_DOOP !== "1") {
|
|
35
|
+
const cmd = process.env.DOOP_MCP_COMMAND || "npx"
|
|
36
|
+
const args = (process.env.DOOP_MCP_ARGS || "-y @arqora/doop-mcp").split(" ")
|
|
37
|
+
out["doop"] = { type: "local", command: [cmd, ...args] }
|
|
38
|
+
}
|
|
39
|
+
if (process.env.CODEX_BRIDGE_DISABLE_CODEGRAPH !== "1") {
|
|
40
|
+
out["codegraph"] = { type: "local", command: ["codegraph", "mcp"] }
|
|
41
|
+
}
|
|
42
|
+
if (process.env.CODEX_BRIDGE_DISABLE_CODEGRAPH_FE !== "1") {
|
|
43
|
+
out["codegraph-fe"] = { type: "local", command: ["codegraph-fe", "mcp"] }
|
|
44
|
+
}
|
|
45
|
+
if (process.env.CODEX_BRIDGE_DISABLE_ANGECOM !== "1") {
|
|
46
|
+
// The angecom MCP lives outside this repo; only register it if the
|
|
47
|
+
// user has it on disk. This keeps the plugin usable from any cwd.
|
|
48
|
+
const home = process.env.HOME || "/tmp"
|
|
49
|
+
const candidate = join(home, "workspace/angecom/angecom-mcp/dist/index.js")
|
|
50
|
+
if (existsSync(candidate)) {
|
|
51
|
+
out["angecom"] = {
|
|
52
|
+
type: "local",
|
|
53
|
+
command: ["node", candidate],
|
|
54
|
+
cwd: join(home, "workspace/angecom/angecom-mcp"),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
function readCache(): string[] {
|
|
27
62
|
if (!existsSync(CACHE)) return []
|
|
28
63
|
try {
|
|
@@ -63,33 +98,97 @@ function mergeModels(provider: any, slugs: string[]): void {
|
|
|
63
98
|
if (!same) provider.models = desired
|
|
64
99
|
}
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
101
|
+
// V2 permission schema: an array of { action, resource, effect } rules.
|
|
102
|
+
// Order matters: the last matching rule wins, so we put broad allow
|
|
103
|
+
// rules first, then explicit ask rules for state-changing tools, and
|
|
104
|
+
// finally a global "ask" catch-all so anything not listed still prompts.
|
|
105
|
+
//
|
|
106
|
+
// Read-only tools are allow-listed (Plus UX: reads run silently).
|
|
107
|
+
// Anything that mutates state or hits the network prompts the user.
|
|
108
|
+
function basePermissionRules(): any[] {
|
|
109
|
+
return [
|
|
110
|
+
// --- Read-only allow rules (built-in) ---
|
|
111
|
+
{ action: "read", resource: "*", effect: "allow" },
|
|
112
|
+
{ action: "glob", resource: "*", effect: "allow" },
|
|
113
|
+
{ action: "grep", resource: "*", effect: "allow" },
|
|
114
|
+
{ action: "list_mcp_resources", resource: "*", effect: "allow" },
|
|
115
|
+
{ action: "read_mcp_resource", resource: "*", effect: "allow" },
|
|
116
|
+
|
|
117
|
+
// --- MCP read-only tools (Doop and codegraph both expose several
|
|
118
|
+
// of these; the <server>_<tool> action pattern matches them). ---
|
|
119
|
+
// We list them explicitly so the saved-approval flow can record
|
|
120
|
+
// each tool separately instead of one broad rule.
|
|
121
|
+
{ action: "doop_list_canvases", resource: "*", effect: "allow" },
|
|
122
|
+
{ action: "doop_get_guidelines", resource: "*", effect: "allow" },
|
|
123
|
+
{ action: "doop_get_reference", resource: "*", effect: "allow" },
|
|
124
|
+
{ action: "doop_get_comments", resource: "*", effect: "allow" },
|
|
125
|
+
{ action: "doop_get_frame", resource: "*", effect: "allow" },
|
|
126
|
+
{ action: "doop_get_frame_screenshot", resource: "*", effect: "allow" },
|
|
127
|
+
{ action: "doop_get_linear_issue", resource: "*", effect: "allow" },
|
|
128
|
+
{ action: "codegraph_codegraph_explore", resource: "*", effect: "allow" },
|
|
129
|
+
{ action: "codegraph-fe_codegraph_explore", resource: "*", effect: "allow" },
|
|
130
|
+
{ action: "angecom_confluence_list_folder", resource: "*", effect: "allow" },
|
|
131
|
+
{ action: "angecom_jira_get_issue", resource: "*", effect: "allow" },
|
|
132
|
+
{ action: "angecom_jira_get_transitions", resource: "*", effect: "allow" },
|
|
133
|
+
{ action: "angecom_jira_list_attachments", resource: "*", effect: "allow" },
|
|
134
|
+
{ action: "angecom_list_workspaces", resource: "*", effect: "allow" },
|
|
135
|
+
{ action: "angecom_list_mcp_resources", resource: "*", effect: "allow" },
|
|
136
|
+
{ action: "opencode_list_mcp_resources", resource: "*", effect: "allow" },
|
|
137
|
+
{ action: "opencode_read_mcp_resource", resource: "*", effect: "allow" },
|
|
138
|
+
|
|
139
|
+
// --- Anything state-changing defaults to ask (Plus behavior). ---
|
|
140
|
+
{ action: "*", resource: "*", effect: "ask" },
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mergePermissions(current: any): any[] {
|
|
145
|
+
const base = basePermissionRules()
|
|
146
|
+
if (!Array.isArray(current)) return base
|
|
147
|
+
// User-defined rules win. We keep the base array as a fallback and
|
|
148
|
+
// append the user rules, so the user's specific "allow" overrides
|
|
149
|
+
// our "ask" catch-all at the bottom.
|
|
150
|
+
return [...base, ...current]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const CodexBridgePlugin = {
|
|
154
|
+
id: "codex-bridge",
|
|
68
155
|
async setup(_ctx: unknown) {
|
|
69
156
|
return {
|
|
70
157
|
config: async (cfg: any) => {
|
|
71
|
-
|
|
72
|
-
if (slugs.length === 0) return
|
|
158
|
+
// --- Provider ---
|
|
73
159
|
if (!cfg.provider) cfg.provider = {}
|
|
74
160
|
if (!cfg.provider[PROVIDER_ID]) {
|
|
75
161
|
cfg.provider[PROVIDER_ID] = {
|
|
76
162
|
npm: "@ai-sdk/openai-compatible",
|
|
77
163
|
name: "ChatGPT Business (codex-bridge)",
|
|
78
|
-
options: { baseURL:
|
|
164
|
+
options: { baseURL: BRIDGE_BASE },
|
|
79
165
|
models: {},
|
|
80
166
|
}
|
|
81
167
|
}
|
|
82
|
-
|
|
168
|
+
const slugs = readCache()
|
|
169
|
+
if (slugs.length > 0) {
|
|
170
|
+
mergeModels(cfg.provider[PROVIDER_ID], slugs)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- MCP servers ---
|
|
174
|
+
if (!cfg.mcp) cfg.mcp = {}
|
|
175
|
+
if (!cfg.mcp.servers) cfg.mcp.servers = {}
|
|
176
|
+
const defaults = defaultMcpServers()
|
|
177
|
+
for (const [name, def] of Object.entries(defaults)) {
|
|
178
|
+
if (!cfg.mcp.servers[name]) cfg.mcp.servers[name] = def
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Permission policy (V2 schema) ---
|
|
182
|
+
cfg.permissions = mergePermissions(cfg.permissions)
|
|
83
183
|
},
|
|
84
184
|
event: async ({ event }: { event: { type?: string } }) => {
|
|
85
185
|
if (event?.type === "session.created") {
|
|
86
|
-
//
|
|
87
|
-
// kept up to date by lib/sync-cache.mjs when codex-bridge start runs.
|
|
186
|
+
// No-op: on-disk config is already kept fresh by lib/sync-cache.mjs.
|
|
88
187
|
}
|
|
89
188
|
},
|
|
90
189
|
}
|
|
91
190
|
},
|
|
92
191
|
}
|
|
93
192
|
|
|
94
|
-
export default
|
|
95
|
-
export {
|
|
193
|
+
export default CodexBridgePlugin
|
|
194
|
+
export { CodexBridgePlugin }
|
package/lib/server.mjs
CHANGED
|
@@ -1,190 +1,384 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// codex-bridge: OpenAI-compatible HTTP server that
|
|
3
|
-
// Business/Team/Plus
|
|
4
|
-
//
|
|
2
|
+
// codex-bridge: OpenAI-compatible HTTP server that talks to ChatGPT
|
|
3
|
+
// Business/Team/Plus via the OpenAI Responses API directly. We do NOT
|
|
4
|
+
// shell out to `codex exec` because the CLI rejects Plus/Business
|
|
5
|
+
// models when invoked with `-m <slug>` and the auth path is simpler to
|
|
6
|
+
// reproduce here.
|
|
7
|
+
//
|
|
8
|
+
// Endpoints:
|
|
5
9
|
// GET /v1/models -> { object:"list", data:[...] }
|
|
6
10
|
// POST /v1/chat/completions -> OpenAI-style streaming or JSON response
|
|
11
|
+
// GET /v1/tools -> list registered MCP tools
|
|
12
|
+
// POST /v1/tools -> register a list of tools
|
|
13
|
+
// DELETE /v1/tools -> clear registry
|
|
14
|
+
//
|
|
15
|
+
// Auth: reads ~/.codex/auth.json, refreshes access_token via the OAuth
|
|
16
|
+
// refresh_token grant at https://auth.openai.com/oauth/token when
|
|
17
|
+
// expired.
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
19
|
+
// Transport: POST https://chatgpt.com/backend-api/codex/responses with
|
|
20
|
+
// Bearer <access_token> and the OpenAI Responses API body shape. We
|
|
21
|
+
// translate Chat Completions <-> Responses for the OpenCode client.
|
|
11
22
|
|
|
12
23
|
import http from "node:http";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
24
|
+
import { readFileSync, existsSync, writeFileSync, appendFileSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import {
|
|
28
|
+
registerTool,
|
|
29
|
+
registerTools,
|
|
30
|
+
listTools,
|
|
31
|
+
clearTools,
|
|
32
|
+
renderToolsSystemBlock,
|
|
33
|
+
} from "./tools-registry.mjs";
|
|
17
34
|
|
|
18
35
|
const PORT = Number(process.env.PORT || 11434);
|
|
19
36
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
20
|
-
const CODEX_HOME =
|
|
21
|
-
process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
37
|
+
const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
22
38
|
const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
23
39
|
const MODELS_CACHE = join(CODEX_HOME, "models_cache.json");
|
|
40
|
+
const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
|
|
41
|
+
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
42
|
+
const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; // public client id used by codex CLI
|
|
43
|
+
const LOG_PATH = process.env.CODEX_BRIDGE_LOG || "/tmp/codex-bridge.log";
|
|
24
44
|
|
|
25
|
-
const codexBin = (() => {
|
|
26
|
-
const fromEnv = process.env.CODEX_BIN;
|
|
27
|
-
if (fromEnv) return fromEnv;
|
|
28
|
-
try {
|
|
29
|
-
const which = spawn("which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
30
|
-
const out = [];
|
|
31
|
-
which.stdout.on("data", (c) => out.push(c));
|
|
32
|
-
which.on("close", () => process.stdout.write(""));
|
|
33
|
-
// Note: ignored; we instead use the env-default path lookup below.
|
|
34
|
-
} catch {}
|
|
35
|
-
return "codex";
|
|
36
|
-
})();
|
|
37
|
-
|
|
38
|
-
// ---- Logging (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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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(`
|
|
60
|
+
throw new Error(`auth.json not found at ${AUTH_PATH}; run \`codex login\` first`);
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
+
_authCache = parsed.tokens;
|
|
69
|
+
_authLoadedAt = Date.now();
|
|
70
|
+
return _authCache;
|
|
68
71
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
// ----
|
|
119
|
+
// ---- models ----
|
|
76
120
|
function listModels() {
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
// ----
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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:
|
|
175
|
-
completion_tokens:
|
|
176
|
-
total_tokens:
|
|
242
|
+
prompt_tokens: u.input_tokens ?? 0,
|
|
243
|
+
completion_tokens: u.output_tokens ?? 0,
|
|
244
|
+
total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
|
|
177
245
|
};
|
|
178
246
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
282
|
-
req.on("close", () => ac.abort());
|
|
484
|
+
log("info", "chat.completions", { model, stream, msgs: messages.length, toolCount: (tools || []).length });
|
|
283
485
|
|
|
284
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
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
|
}
|