@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 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 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 }
@@ -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
+ }