@jaralabs/codex-bridge 0.2.0 → 0.4.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/auth.mjs +79 -0
- package/lib/capabilities.mjs +183 -0
- package/lib/plugin.ts +122 -23
- package/lib/resilience.mjs +182 -0
- package/lib/server.mjs +379 -208
- package/lib/tools-registry.mjs +57 -0
- package/package.json +11 -4
package/lib/auth.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// auth.mjs: load, cache, and refresh the ChatGPT account tokens that
|
|
2
|
+
// live in ~/.codex/auth.json. The bridge never writes the password
|
|
3
|
+
// back to the file; only the refreshed tokens. The cache TTL is short
|
|
4
|
+
// (30s) so an external `codex login` is picked up without a bridge
|
|
5
|
+
// restart, and refresh is triggered by 401 from the upstream API.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
12
|
+
const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
13
|
+
const AUTH_TTL_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
16
|
+
const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
17
|
+
|
|
18
|
+
let _authCache = null;
|
|
19
|
+
let _authLoadedAt = 0;
|
|
20
|
+
|
|
21
|
+
export function loadAuth() {
|
|
22
|
+
if (!existsSync(AUTH_PATH)) {
|
|
23
|
+
throw new Error(`auth.json not found at ${AUTH_PATH}; run \`codex login\` first`);
|
|
24
|
+
}
|
|
25
|
+
if (_authCache && Date.now() - _authLoadedAt < AUTH_TTL_MS) return _authCache;
|
|
26
|
+
const raw = readFileSync(AUTH_PATH, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (parsed.auth_mode !== "chatgpt") {
|
|
29
|
+
throw new Error(`auth.json auth_mode=${parsed.auth_mode}; only chatgpt supported`);
|
|
30
|
+
}
|
|
31
|
+
_authCache = parsed.tokens;
|
|
32
|
+
_authLoadedAt = Date.now();
|
|
33
|
+
return _authCache;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function refreshAccessToken(refreshToken) {
|
|
37
|
+
const body = new URLSearchParams({
|
|
38
|
+
grant_type: "refresh_token",
|
|
39
|
+
refresh_token: refreshToken,
|
|
40
|
+
client_id: OAUTH_CLIENT_ID,
|
|
41
|
+
scope: "openid profile email offline-access",
|
|
42
|
+
});
|
|
43
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: body.toString(),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
throw new Error(`oauth refresh failed ${res.status}: ${text.slice(0, 300)}`);
|
|
51
|
+
}
|
|
52
|
+
const json = await res.json();
|
|
53
|
+
const current = _authCache || loadAuth();
|
|
54
|
+
const updated = {
|
|
55
|
+
...current,
|
|
56
|
+
access_token: json.access_token,
|
|
57
|
+
refresh_token: json.refresh_token || current.refresh_token,
|
|
58
|
+
id_token: json.id_token || current.id_token,
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(AUTH_PATH, "utf8");
|
|
62
|
+
const file = JSON.parse(raw);
|
|
63
|
+
file.tokens = updated;
|
|
64
|
+
writeFileSync(AUTH_PATH, JSON.stringify(file, null, 2));
|
|
65
|
+
} catch {
|
|
66
|
+
// best-effort; next refresh will try again
|
|
67
|
+
}
|
|
68
|
+
_authCache = updated;
|
|
69
|
+
_authLoadedAt = Date.now();
|
|
70
|
+
return updated;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getAuth() {
|
|
74
|
+
const a = loadAuth();
|
|
75
|
+
if (!a.access_token && a.refresh_token) {
|
|
76
|
+
return refreshAccessToken(a.refresh_token);
|
|
77
|
+
}
|
|
78
|
+
return a;
|
|
79
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// capabilities.mjs: probe the upstream ChatGPT Responses API at boot
|
|
2
|
+
// and persist a small capability record to ~/.codex/bridge_capabilities.json.
|
|
3
|
+
//
|
|
4
|
+
// The probe sends a minimal request (one user message, no tools) and
|
|
5
|
+
// records:
|
|
6
|
+
//
|
|
7
|
+
// * which request fields the API accepted (or rejected) so we can
|
|
8
|
+
// detect a wire-shape drift the next time the bridge starts;
|
|
9
|
+
// * the round-trip latency;
|
|
10
|
+
// * the resolved model id (so we know if the model slug was renamed
|
|
11
|
+
// or routed to a different one);
|
|
12
|
+
// * the response shape: did we get an event stream? Did the
|
|
13
|
+
// response.completed event arrive?
|
|
14
|
+
//
|
|
15
|
+
// The next boot compares the new probe against the previous record.
|
|
16
|
+
// If the shape changes in a way we did not anticipate, we log a
|
|
17
|
+
// warn and (optionally) refuse to start.
|
|
18
|
+
//
|
|
19
|
+
// The probe is best-effort. If it fails, the bridge still starts:
|
|
20
|
+
// the real chat handler will surface a clearer error on the first
|
|
21
|
+
// request and the operator can see what changed.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { getAuth } from "./auth.mjs";
|
|
27
|
+
|
|
28
|
+
const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
29
|
+
const CAPS_PATH = join(CODEX_HOME, "bridge_capabilities.json");
|
|
30
|
+
const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
|
|
31
|
+
|
|
32
|
+
const PROBE_MODEL = process.env.CODEX_BRIDGE_PROBE_MODEL || "gpt-6-luna";
|
|
33
|
+
|
|
34
|
+
// The list of body fields we send on every request. The probe
|
|
35
|
+
// checks which of these the API still accepts. If a previously
|
|
36
|
+
// accepted field is now rejected, we log a strong warn.
|
|
37
|
+
const PROBE_FIELDS = [
|
|
38
|
+
"model",
|
|
39
|
+
"store",
|
|
40
|
+
"stream",
|
|
41
|
+
"instructions",
|
|
42
|
+
"input",
|
|
43
|
+
"tool_choice",
|
|
44
|
+
"parallel_tool_calls",
|
|
45
|
+
"prompt_cache_key",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export async function probeCapabilities({ log = console.log.bind(console) } = {}) {
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
const auth = await getAuth();
|
|
53
|
+
const body = {
|
|
54
|
+
model: PROBE_MODEL,
|
|
55
|
+
store: false,
|
|
56
|
+
stream: true,
|
|
57
|
+
input: [
|
|
58
|
+
{
|
|
59
|
+
type: "message",
|
|
60
|
+
role: "user",
|
|
61
|
+
content: [{ type: "input_text", text: "ping" }],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
prompt_cache_key: `bridge-probe-${Date.now()}`,
|
|
65
|
+
};
|
|
66
|
+
const res = await fetch(`${CHATGPT_BASE}/responses`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
signal: AbortSignal.timeout(15_000),
|
|
69
|
+
headers: {
|
|
70
|
+
authorization: `Bearer ${auth.access_token}`,
|
|
71
|
+
"content-type": "application/json",
|
|
72
|
+
accept: "text/event-stream",
|
|
73
|
+
"OpenAI-Beta": "responses=experimental",
|
|
74
|
+
"chatgpt-account-id": auth.account_id || "",
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
result = { ok: false, status: res.status, error: text.slice(0, 400), latencyMs: Date.now() - start };
|
|
81
|
+
} else {
|
|
82
|
+
// Read the SSE stream until the first response.completed or
|
|
83
|
+
// error event, then bail. We do not need the full response.
|
|
84
|
+
const reader = res.body.getReader();
|
|
85
|
+
const decoder = new TextDecoder("utf8");
|
|
86
|
+
let buf = "";
|
|
87
|
+
let completed = false;
|
|
88
|
+
let resolvedModel = null;
|
|
89
|
+
let firstEvent = null;
|
|
90
|
+
const events = new Set();
|
|
91
|
+
while (!completed) {
|
|
92
|
+
const { value, done } = await reader.read();
|
|
93
|
+
if (done) break;
|
|
94
|
+
buf += decoder.decode(value, { stream: true });
|
|
95
|
+
let idx;
|
|
96
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
97
|
+
const block = buf.slice(0, idx);
|
|
98
|
+
buf = buf.slice(idx + 2);
|
|
99
|
+
let data = "";
|
|
100
|
+
for (const line of block.split("\n")) {
|
|
101
|
+
if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
102
|
+
}
|
|
103
|
+
if (!data || data === "[DONE]") continue;
|
|
104
|
+
let json;
|
|
105
|
+
try { json = JSON.parse(data); } catch { continue; }
|
|
106
|
+
events.add(json.type);
|
|
107
|
+
if (!firstEvent) firstEvent = json.type;
|
|
108
|
+
if (json.type === "response.completed") {
|
|
109
|
+
completed = true;
|
|
110
|
+
resolvedModel = json.response?.model || null;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (json.type === "error" || json.type === "response.failed") {
|
|
114
|
+
completed = true;
|
|
115
|
+
result = { ok: false, status: res.status, error: json.message || json.error || "unknown", events: Array.from(events) };
|
|
116
|
+
try { await reader.cancel(); } catch {}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!result) {
|
|
122
|
+
result = {
|
|
123
|
+
ok: true,
|
|
124
|
+
status: res.status,
|
|
125
|
+
latencyMs: Date.now() - start,
|
|
126
|
+
resolvedModel,
|
|
127
|
+
firstEvent,
|
|
128
|
+
events: Array.from(events),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
result = { ok: false, error: e?.message || String(e), latencyMs: Date.now() - start };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Compare against the previous record.
|
|
137
|
+
const previous = readPrevious();
|
|
138
|
+
const record = {
|
|
139
|
+
probedAt: new Date().toISOString(),
|
|
140
|
+
probeModel: PROBE_MODEL,
|
|
141
|
+
probeFields: PROBE_FIELDS,
|
|
142
|
+
...result,
|
|
143
|
+
};
|
|
144
|
+
if (previous) {
|
|
145
|
+
const drift = detectDrift(previous, record);
|
|
146
|
+
if (drift.length > 0) {
|
|
147
|
+
log("warn", "capabilities.drift", { drift });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
mkdirSync(CODEX_HOME, { recursive: true });
|
|
152
|
+
writeFileSync(CAPS_PATH, JSON.stringify(record, null, 2));
|
|
153
|
+
} catch (e) {
|
|
154
|
+
// best-effort: do not block startup on disk write failure
|
|
155
|
+
}
|
|
156
|
+
return record;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readPrevious() {
|
|
160
|
+
if (!existsSync(CAPS_PATH)) return null;
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(readFileSync(CAPS_PATH, "utf8"));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function detectDrift(prev, next) {
|
|
169
|
+
const out = [];
|
|
170
|
+
if (prev.ok !== next.ok) {
|
|
171
|
+
out.push({ kind: "ok", before: prev.ok, after: next.ok });
|
|
172
|
+
}
|
|
173
|
+
if (prev.resolvedModel && next.resolvedModel && prev.resolvedModel !== next.resolvedModel) {
|
|
174
|
+
out.push({ kind: "resolvedModel", before: prev.resolvedModel, after: next.resolvedModel });
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(prev.events) && Array.isArray(next.events)) {
|
|
177
|
+
const lost = prev.events.filter((e) => !next.events.includes(e));
|
|
178
|
+
const gained = next.events.filter((e) => !prev.events.includes(e));
|
|
179
|
+
if (lost.length > 0) out.push({ kind: "eventsLost", events: lost });
|
|
180
|
+
if (gained.length > 0) out.push({ kind: "eventsGained", events: gained });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
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 }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// resilience.mjs: backoff retry, circuit breaker, and in-memory metrics
|
|
2
|
+
// for the upstream ChatGPT Responses API.
|
|
3
|
+
//
|
|
4
|
+
// Goals:
|
|
5
|
+
// * Survive transient 429/5xx without surfacing the error to the
|
|
6
|
+
// client when a quick retry would have worked.
|
|
7
|
+
// * Stop hammering the API when it is clearly down (circuit
|
|
8
|
+
// breaker) so the bridge does not amplify an outage.
|
|
9
|
+
// * Surface a small set of counters at /v1/health so an operator
|
|
10
|
+
// can see whether the bridge is healthy without log scraping.
|
|
11
|
+
//
|
|
12
|
+
// This module is in-memory and process-local. State resets on
|
|
13
|
+
// restart, which is fine for a single-process bridge.
|
|
14
|
+
|
|
15
|
+
import { appendFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
18
|
+
const DEFAULT_MAX_RETRIES = 4;
|
|
19
|
+
const DEFAULT_BASE_MS = 400;
|
|
20
|
+
const DEFAULT_MAX_MS = 8_000;
|
|
21
|
+
const CIRCUIT_FAIL_THRESHOLD = 5; // consecutive failures to open
|
|
22
|
+
const CIRCUIT_WINDOW_MS = 30_000; // failures older than this drop off
|
|
23
|
+
const CIRCUIT_COOLDOWN_MS = 15_000; // how long we stay open before half-open
|
|
24
|
+
|
|
25
|
+
const state = {
|
|
26
|
+
// metrics
|
|
27
|
+
requests: 0,
|
|
28
|
+
successes: 0,
|
|
29
|
+
failures: 0,
|
|
30
|
+
retries: 0,
|
|
31
|
+
lastError: null,
|
|
32
|
+
lastSuccessAt: 0,
|
|
33
|
+
lastFailureAt: 0,
|
|
34
|
+
// circuit breaker
|
|
35
|
+
failureTimes: [], // timestamps of recent failures
|
|
36
|
+
circuitOpenedAt: 0, // 0 = closed; >0 = open since this timestamp
|
|
37
|
+
halfOpenProbe: null, // one in-flight probe during half-open
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function recordSuccess() {
|
|
41
|
+
state.requests += 1;
|
|
42
|
+
state.successes += 1;
|
|
43
|
+
state.lastSuccessAt = Date.now();
|
|
44
|
+
state.lastError = null;
|
|
45
|
+
// A success closes the circuit if we are in half-open.
|
|
46
|
+
if (state.circuitOpenedAt) {
|
|
47
|
+
state.circuitOpenedAt = 0;
|
|
48
|
+
state.halfOpenProbe = null;
|
|
49
|
+
log("info", "circuit.closed");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recordFailure(err) {
|
|
54
|
+
state.requests += 1;
|
|
55
|
+
state.failures += 1;
|
|
56
|
+
state.lastFailureAt = Date.now();
|
|
57
|
+
state.lastError = err?.message || String(err);
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
state.failureTimes = state.failureTimes.filter((t) => now - t < CIRCUIT_WINDOW_MS);
|
|
60
|
+
state.failureTimes.push(now);
|
|
61
|
+
if (
|
|
62
|
+
state.circuitOpenedAt === 0 &&
|
|
63
|
+
state.failureTimes.length >= CIRCUIT_FAIL_THRESHOLD
|
|
64
|
+
) {
|
|
65
|
+
state.circuitOpenedAt = now;
|
|
66
|
+
log("warn", "circuit.opened", { failures: state.failureTimes.length, windowMs: CIRCUIT_WINDOW_MS });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns true if the breaker permits a request right now. If the
|
|
71
|
+
// breaker is open but the cooldown has elapsed, returns true once
|
|
72
|
+
// (half-open probe); the caller must call recordSuccess or
|
|
73
|
+
// recordFailure to close or re-open the circuit.
|
|
74
|
+
export function circuitPermits() {
|
|
75
|
+
if (state.circuitOpenedAt === 0) return true;
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const openFor = now - state.circuitOpenedAt;
|
|
78
|
+
if (openFor < CIRCUIT_COOLDOWN_MS) return false;
|
|
79
|
+
if (state.halfOpenProbe) return false;
|
|
80
|
+
state.halfOpenProbe = "in-flight";
|
|
81
|
+
log("info", "circuit.half_open", { openForMs: openFor });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function circuitStatus() {
|
|
86
|
+
if (state.circuitOpenedAt === 0) return "closed";
|
|
87
|
+
const openFor = Date.now() - state.circuitOpenedAt;
|
|
88
|
+
if (openFor < CIRCUIT_COOLDOWN_MS) return "open";
|
|
89
|
+
return "half_open";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Compute the backoff delay before the next attempt. attempt is
|
|
93
|
+
// 0-indexed: 0 means the first retry, etc.
|
|
94
|
+
export function backoffMs(attempt) {
|
|
95
|
+
const exp = Math.min(DEFAULT_MAX_MS, DEFAULT_BASE_MS * 2 ** attempt);
|
|
96
|
+
// Full jitter: random in [0, exp].
|
|
97
|
+
return Math.floor(Math.random() * exp);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isRetryableStatus(status) {
|
|
101
|
+
return RETRYABLE_STATUS.has(status);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const retryConfig = {
|
|
105
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
106
|
+
baseMs: DEFAULT_BASE_MS,
|
|
107
|
+
maxMs: DEFAULT_MAX_MS,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function metrics() {
|
|
111
|
+
return {
|
|
112
|
+
requests: state.requests,
|
|
113
|
+
successes: state.successes,
|
|
114
|
+
failures: state.failures,
|
|
115
|
+
retries: state.retries,
|
|
116
|
+
lastError: state.lastError,
|
|
117
|
+
lastSuccessAt: state.lastSuccessAt,
|
|
118
|
+
lastFailureAt: state.lastFailureAt,
|
|
119
|
+
circuit: circuitStatus(),
|
|
120
|
+
circuitFailuresInWindow: state.failureTimes.length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Wrap an async function with retry + circuit breaker semantics.
|
|
125
|
+
// fn() must throw an Error with .status attached when the response
|
|
126
|
+
// is a retryable HTTP error. onRetry(attempt, delayMs, err) is
|
|
127
|
+
// called before sleeping between attempts.
|
|
128
|
+
export async function withResilience(label, fn, { onRetry, signal } = {}) {
|
|
129
|
+
if (!circuitPermits()) {
|
|
130
|
+
const err = new Error(`circuit open: ${label}`);
|
|
131
|
+
err.status = 503;
|
|
132
|
+
err.code = "circuit_open";
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
let lastErr;
|
|
136
|
+
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
|
|
137
|
+
if (signal?.aborted) {
|
|
138
|
+
const e = new Error("aborted");
|
|
139
|
+
e.code = "aborted";
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const result = await fn(attempt);
|
|
144
|
+
recordSuccess();
|
|
145
|
+
return result;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
lastErr = err;
|
|
148
|
+
const status = err?.status;
|
|
149
|
+
const retryable = isRetryableStatus(status) || err?.code === "fetch_failed";
|
|
150
|
+
if (!retryable || attempt === retryConfig.maxRetries) {
|
|
151
|
+
recordFailure(err);
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
state.retries += 1;
|
|
155
|
+
const delay = backoffMs(attempt);
|
|
156
|
+
if (onRetry) onRetry(attempt, delay, err);
|
|
157
|
+
log("warn", "retry", { label, attempt: attempt + 1, status, delayMs: delay, err: err?.message });
|
|
158
|
+
if (signal) {
|
|
159
|
+
await new Promise((resolve, reject) => {
|
|
160
|
+
const t = setTimeout(resolve, delay);
|
|
161
|
+
signal.addEventListener("abort", () => {
|
|
162
|
+
clearTimeout(t);
|
|
163
|
+
reject(Object.assign(new Error("aborted"), { code: "aborted" }));
|
|
164
|
+
}, { once: true });
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
recordFailure(lastErr);
|
|
172
|
+
throw lastErr;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function log(level, msg, extra) {
|
|
176
|
+
try {
|
|
177
|
+
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...(extra || {}) });
|
|
178
|
+
appendFileSync(process.env.CODEX_BRIDGE_LOG || "/tmp/codex-bridge.log", line + "\n");
|
|
179
|
+
} catch {
|
|
180
|
+
// best-effort logging; never throw from the resilience layer
|
|
181
|
+
}
|
|
182
|
+
}
|