@jaralabs/codex-bridge 0.3.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
+ }
@@ -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
+ }
package/lib/server.mjs CHANGED
@@ -1,29 +1,25 @@
1
1
  #!/usr/bin/env node
2
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.
3
+ // Business/Team/Plus via the OpenAI Responses API directly.
7
4
  //
8
5
  // Endpoints:
9
- // GET /v1/models -> { object:"list", data:[...] }
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
6
+ // GET /v1/health -> { ok, ...metrics }
7
+ // GET /v1/models -> { object:"list", data:[...] }
8
+ // POST /v1/chat/completions -> OpenAI-style streaming or JSON response
9
+ // GET /v1/tools -> list registered MCP tools
10
+ // POST /v1/tools -> register a list of tools
11
+ // DELETE /v1/tools -> clear registry
14
12
  //
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.
18
- //
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.
13
+ // Auth lives in lib/auth.mjs. Upstream resilience (retry, circuit
14
+ // breaker, metrics) lives in lib/resilience.mjs. Boot-time capability
15
+ // probing lives in lib/capabilities.mjs.
22
16
 
23
17
  import http from "node:http";
24
- import { readFileSync, existsSync, writeFileSync, appendFileSync } from "node:fs";
18
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
25
19
  import { homedir } from "node:os";
26
20
  import { join } from "node:path";
21
+
22
+ import { getAuth, refreshAccessToken } from "./auth.mjs";
27
23
  import {
28
24
  registerTool,
29
25
  registerTools,
@@ -31,15 +27,17 @@ import {
31
27
  clearTools,
32
28
  renderToolsSystemBlock,
33
29
  } from "./tools-registry.mjs";
30
+ import {
31
+ withResilience,
32
+ metrics as resilienceMetrics,
33
+ } from "./resilience.mjs";
34
+ import { probeCapabilities } from "./capabilities.mjs";
34
35
 
35
36
  const PORT = Number(process.env.PORT || 11434);
36
37
  const HOST = process.env.HOST || "127.0.0.1";
37
38
  const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
38
- const AUTH_PATH = join(CODEX_HOME, "auth.json");
39
39
  const MODELS_CACHE = join(CODEX_HOME, "models_cache.json");
40
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
41
  const LOG_PATH = process.env.CODEX_BRIDGE_LOG || "/tmp/codex-bridge.log";
44
42
 
45
43
  function log(level, msg, extra) {
@@ -50,72 +48,6 @@ function log(level, msg, extra) {
50
48
  }
51
49
  }
52
50
 
53
- // ---- auth ----
54
- let _authCache = null;
55
- let _authLoadedAt = 0;
56
- const AUTH_TTL_MS = 30_000; // re-read the file every 30s
57
-
58
- function loadAuth() {
59
- if (!existsSync(AUTH_PATH)) {
60
- throw new Error(`auth.json not found at ${AUTH_PATH}; run \`codex login\` first`);
61
- }
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`);
67
- }
68
- _authCache = parsed.tokens;
69
- _authLoadedAt = Date.now();
70
- return _authCache;
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;
117
- }
118
-
119
51
  // ---- models ----
120
52
  function listModels() {
121
53
  if (!existsSync(MODELS_CACHE)) {
@@ -136,17 +68,6 @@ function listModels() {
136
68
  }
137
69
 
138
70
  // ---- 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
71
 
151
72
  function chatToolsToResponsesTools(tools) {
152
73
  if (!Array.isArray(tools) || tools.length === 0) return undefined;
@@ -166,8 +87,6 @@ function chatToolsToResponsesTools(tools) {
166
87
  }
167
88
 
168
89
  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
90
  let instructions = "";
172
91
  const input = [];
173
92
  for (const m of messages) {
@@ -184,8 +103,6 @@ function chatMessagesToResponsesInput(messages) {
184
103
  continue;
185
104
  }
186
105
  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
106
  if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
190
107
  for (const tc of m.tool_calls) {
191
108
  const name = tc.function?.name || tc.name;
@@ -262,47 +179,57 @@ async function callResponses({ model, instructions, input, tools, signal }) {
262
179
  if (tools && tools.length > 0) body.tools = tools;
263
180
 
264
181
  const headers = {
265
- "authorization": `Bearer ${auth.access_token}`,
182
+ authorization: `Bearer ${auth.access_token}`,
266
183
  "content-type": "application/json",
267
- "accept": "text/event-stream",
184
+ accept: "text/event-stream",
268
185
  "OpenAI-Beta": "responses=experimental",
269
186
  "chatgpt-account-id": auth.account_id || "",
270
187
  };
271
188
 
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)}`);
189
+ log("info", "responses.request", {
190
+ model,
191
+ instructionsLen: (instructions || "").length,
192
+ inputItems: input.length,
193
+ tools: (tools || []).length,
194
+ });
195
+
196
+ const doFetch = async () => {
197
+ let res;
198
+ try {
199
+ res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
200
+ } catch (e) {
201
+ const err = new Error(`fetch failed: ${e?.message || e}`);
202
+ err.code = "fetch_failed";
203
+ err.status = 0;
204
+ throw err;
283
205
  }
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;
206
+ if (res.status === 401 && auth.refresh_token) {
207
+ log("info", "responses.401 refreshing token");
208
+ const refreshed = await refreshAccessToken(auth.refresh_token);
209
+ headers.authorization = `Bearer ${refreshed.access_token}`;
210
+ res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
211
+ }
212
+ if (!res.ok) {
213
+ const text = await res.text();
214
+ const err = new Error(`chatgpt ${res.status}: ${text.slice(0, 400)}`);
215
+ err.status = res.status;
216
+ throw err;
217
+ }
218
+ return res;
219
+ };
220
+
221
+ return withResilience("responses", doFetch, { signal });
291
222
  }
292
223
 
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.
224
+ // Parse SSE stream from Responses API.
298
225
  async function readSse(res) {
299
226
  const reader = res.body.getReader();
300
227
  const decoder = new TextDecoder("utf8");
301
228
  let buf = "";
302
229
  let text = "";
303
- const toolCalls = new Map(); // call_id -> { id, name, arguments }
230
+ const toolCalls = new Map();
304
231
  let usage = null;
305
- let currentItem = null; // { type, id?, call_id?, name?, content_index?, delta? }
232
+ let currentItem = null;
306
233
 
307
234
  function ensureCall(id, name) {
308
235
  if (!toolCalls.has(id)) toolCalls.set(id, { id, name: name || "", arguments: "" });
@@ -317,7 +244,6 @@ async function readSse(res) {
317
244
  while ((idx = buf.indexOf("\n\n")) !== -1) {
318
245
  const block = buf.slice(0, idx);
319
246
  buf = buf.slice(idx + 2);
320
- // Parse "event:" and "data:" lines.
321
247
  let event = "";
322
248
  let data = "";
323
249
  for (const line of block.split("\n")) {
@@ -361,7 +287,6 @@ async function readSse(res) {
361
287
  case "response.completed": {
362
288
  const r = json.response || {};
363
289
  if (r.usage) usage = r.usage;
364
- // Synthesize text from output if we missed deltas.
365
290
  if (!text && Array.isArray(r.output)) {
366
291
  for (const item of r.output) {
367
292
  if (item.type === "message") {
@@ -388,6 +313,14 @@ const server = http.createServer(async (req, res) => {
388
313
  if (req.method === "GET" && url.pathname === "/healthz") {
389
314
  return json(res, 200, { ok: true, port: PORT });
390
315
  }
316
+ if (req.method === "GET" && url.pathname === "/v1/health") {
317
+ return json(res, 200, {
318
+ ok: true,
319
+ port: PORT,
320
+ uptime: process.uptime(),
321
+ ...resilienceMetrics(),
322
+ });
323
+ }
391
324
  if (req.method === "GET" && url.pathname === "/v1/models") {
392
325
  return json(res, 200, listModels());
393
326
  }
@@ -469,9 +402,6 @@ async function handleChatCompletions(payload, req, res) {
469
402
  const ac = new AbortController();
470
403
  req.on("close", () => ac.abort());
471
404
 
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
405
  const responsesTools = chatToolsToResponsesTools(tools);
476
406
  let { instructions, input } = chatMessagesToResponsesInput(messages);
477
407
  if (!(responsesTools && responsesTools.length > 0)) {
@@ -483,10 +413,6 @@ async function handleChatCompletions(payload, req, res) {
483
413
 
484
414
  log("info", "chat.completions", { model, stream, msgs: messages.length, toolCount: (tools || []).length });
485
415
 
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.
490
416
  let result;
491
417
  try {
492
418
  const upstream = await callResponses({
@@ -498,7 +424,17 @@ async function handleChatCompletions(payload, req, res) {
498
424
  });
499
425
  result = await readSse(upstream);
500
426
  } catch (e) {
501
- return json(res, 502, { error: { message: String(e.message || e), type: "upstream_error" } });
427
+ const status = e?.status || 502;
428
+ const body = {
429
+ error: {
430
+ message: String(e.message || e),
431
+ type: e?.code || "upstream_error",
432
+ },
433
+ };
434
+ if (status === 503 && e?.code === "circuit_open") {
435
+ return json(res, 503, { ...body, error: { ...body.error, message: "bridge: circuit open, upstream unavailable" } });
436
+ }
437
+ return json(res, status === 401 ? 502 : status, body);
502
438
  }
503
439
 
504
440
  const id = genId("chatcmpl");
@@ -551,7 +487,6 @@ async function handleChatCompletions(payload, req, res) {
551
487
  choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
552
488
  });
553
489
  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.
555
490
  res.write("data: [DONE]\n\n");
556
491
  return res.end();
557
492
  }
@@ -563,10 +498,21 @@ async function handleChatCompletions(payload, req, res) {
563
498
  });
564
499
  }
565
500
 
566
- server.listen(PORT, HOST, () => {
567
- log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
568
- process.stderr.write(`codex-bridge listening on http://${HOST}:${PORT}\n`);
569
- });
501
+ // ---- boot ----
502
+ async function boot() {
503
+ try {
504
+ const caps = await probeCapabilities({ log: (level, msg, extra) => log(level, msg, extra) });
505
+ log("info", "capabilities", { ok: caps.ok, resolvedModel: caps.resolvedModel, events: caps.events, latencyMs: caps.latencyMs });
506
+ } catch (e) {
507
+ log("warn", "probe failed; starting anyway", { err: String(e) });
508
+ }
509
+ server.listen(PORT, HOST, () => {
510
+ log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
511
+ process.stderr.write(`codex-bridge listening on http://${HOST}:${PORT}\n`);
512
+ });
513
+ }
570
514
 
571
515
  process.on("SIGTERM", () => server.close(() => process.exit(0)));
572
516
  process.on("SIGINT", () => server.close(() => process.exit(0)));
517
+
518
+ boot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaralabs/codex-bridge",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -24,7 +24,7 @@
24
24
  "access": "public"
25
25
  },
26
26
  "engines": {
27
- "node": ">=18.0.0"
27
+ "node": ">=20.0.0"
28
28
  },
29
29
  "scripts": {
30
30
  "start": "bin/codex-bridge start",
@@ -33,7 +33,9 @@
33
33
  "sync": "bin/codex-bridge sync",
34
34
  "logs": "bin/codex-bridge logs",
35
35
  "test": "node test/test.mjs",
36
- "test:tools": "node test/test-tools.mjs"
36
+ "test:tools": "node test/test-tools.mjs",
37
+ "test:resilience": "node test/test-resilience.mjs",
38
+ "test:smoke": "node test/smoke.mjs"
37
39
  },
38
40
  "repository": {
39
41
  "type": "git",