@jaralabs/codex-bridge 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -1,22 +1,36 @@
1
1
  # @jaralabs/codex-bridge
2
2
 
3
- > Use your ChatGPT Business / Team / Plus subscription from OpenCode instead of paying for the OpenAI API.
3
+ > Use your ChatGPT Business / Team / Plus subscription from OpenCode instead
4
+ > of paying for the OpenAI API.
4
5
 
5
6
  `codex-bridge` runs an OpenAI-compatible HTTP server on `127.0.0.1:11434` that
6
- forwards chat-completion requests to the [OpenAI Codex CLI][codex]. Because
7
- the Codex CLI reuses the local ChatGPT OAuth session in `~/.codex/auth.json`,
8
- your OpenCode prompts consume your subscription credits — not the OpenAI API
9
- balance.
10
-
11
- [codex]: https://github.com/openai/codex
7
+ forwards chat-completion requests to the ChatGPT Responses API
8
+ (`https://chatgpt.com/backend-api/codex/responses`) using the OAuth session in
9
+ `~/.codex/auth.json`. Your OpenCode prompts consume your subscription credits
10
+ — not the OpenAI API balance.
11
+
12
+ The bridge is a thin, durable proxy:
13
+
14
+ * No dependency on the Codex CLI at runtime. (The CLI rejects ChatGPT Plus
15
+ models when invoked directly; the bridge speaks the same wire format
16
+ itself.)
17
+ * Auto-refreshes the access token via `https://auth.openai.com/oauth/token`
18
+ when the API returns 401, and persists the new tokens to `auth.json`.
19
+ * Wraps every upstream call in retry-with-jitter and a circuit breaker, so
20
+ transient 429/5xx errors do not bubble to OpenCode.
21
+ * Probes the upstream API at boot and persists the wire shape to
22
+ `~/.codex/bridge_capabilities.json`. A drift on the next boot (model
23
+ rename, missing event, status flip) is logged as a warning.
24
+ * Exposes `/v1/health` for in-process resilience metrics (requests, retries,
25
+ circuit state).
12
26
 
13
27
  ## Why
14
28
 
15
29
  If you have a ChatGPT Business / Team / Plus plan, the OpenAI API and the
16
- ChatGPT web subscription are billed separately. You can use Codex CLI / web
17
- App today with the subscription. `codex-bridge` exposes the same models over
18
- an OpenAI-compatible HTTP surface so OpenCode (and any other OpenAI client
19
- like Claude Code, Cline, aider) can use them without an API key.
30
+ ChatGPT web subscription are billed separately. You can use the Codex CLI /
31
+ web App today with the subscription. `codex-bridge` exposes the same models
32
+ over an OpenAI-compatible HTTP surface so OpenCode (and any other OpenAI
33
+ client like Claude Code, Cline, aider) can use them without an API key.
20
34
 
21
35
  ## Install
22
36
 
@@ -24,76 +38,143 @@ like Claude Code, Cline, aider) can use them without an API key.
24
38
  npm install -g @jaralabs/codex-bridge
25
39
  ```
26
40
 
27
- Requires Node 18 or newer and the OpenAI Codex CLI installed and logged in
28
- once with `codex login` on a machine with the ChatGPT Business / Team / Plus
29
- session.
41
+ Requires:
30
42
 
31
- ## Wire it into OpenCode
43
+ * Node.js 20 or newer.
44
+ * The OpenAI Codex CLI installed and logged in **once** with `codex login`
45
+ on a machine with the ChatGPT Business / Team / Plus session. The bridge
46
+ reads `~/.codex/auth.json` for the OAuth tokens. After the first login,
47
+ the CLI is no longer required at runtime.
32
48
 
33
- Add a `chatgpt` provider to `~/.config/opencode/opencode.jsonc`:
34
-
35
- ```json
36
- {
37
- "provider": {
38
- "chatgpt": {
39
- "npm": "@ai-sdk/openai-compatible",
40
- "name": "ChatGPT Business (codex-bridge)",
41
- "options": {
42
- "baseURL": "http://127.0.0.1:11434/v1"
43
- },
44
- "models": {
45
- "gpt-6.1-sol": {
46
- "name": "GPT-6.1 Sol (Business)",
47
- "limit": { "context": 272000, "output": 128000 },
48
- "modalities": { "input": ["text", "image"], "output": ["text"] }
49
- }
50
- }
51
- }
52
- }
53
- }
54
- ```
49
+ ## Wire it into OpenCode
55
50
 
56
- The exact model list depends on what your plan exposes. `codex-bridge start`
57
- syncs the model list from `~/.codex/models_cache.json` (refreshed by Codex
58
- whenever it talks to ChatGPT) and rewrites the `provider.chatgpt.models`
59
- block to match. Re-run `codex-bridge start` after any plan change.
51
+ There are two pieces: the bridge running on `127.0.0.1:11434`, and the
52
+ OpenCode plugin that registers the `chatgpt` provider plus the default MCP
53
+ servers.
60
54
 
61
- ## Usage
55
+ ### 1. Run the bridge
62
56
 
63
57
  ```bash
64
58
  codex-bridge start # sync models + start HTTP server in background
65
59
  codex-bridge status # is it up?
66
- codex-bridge sync # only sync model list (no server)
67
60
  codex-bridge logs # tail the server log
68
61
  codex-bridge stop # kill the server
69
62
  ```
70
63
 
71
- In another terminal:
64
+ Other useful commands:
65
+
66
+ ```bash
67
+ codex-bridge sync # only sync the model list (no server)
68
+ ```
69
+
70
+ ### 2. Install the OpenCode plugin
71
+
72
+ Copy or symlink `lib/plugin.ts` to `~/.config/opencode/plugins/codex-bridge.ts`:
72
73
 
73
74
  ```bash
74
- opencode run --model=chatgpt/gpt-6.1-sol "say hi"
75
+ mkdir -p ~/.config/opencode/plugins
76
+ ln -s "$(npm root -g)/@jaralabs/codex-bridge/lib/plugin.ts" \
77
+ ~/.config/opencode/plugins/codex-bridge.ts
75
78
  ```
76
79
 
80
+ The plugin:
81
+
82
+ * Registers the `chatgpt` provider pointing at `http://127.0.0.1:11434/v1`.
83
+ * Adds the live model list from `~/.codex/models_cache.json`.
84
+ * Registers the default MCP servers (Doop, codegraph, codegraph-fe, angecom).
85
+ Disable per-instance with `CODEX_BRIDGE_DISABLE_<NAME>=1`.
86
+ * Seeds a V2 permission policy: read-only tools are `allow`, anything that
87
+ mutates state is `ask`, mirroring the ChatGPT Plus UX.
88
+
89
+ Restart OpenCode after installing the plugin so it picks it up.
90
+
91
+ ### 3. Pick a model
92
+
93
+ In OpenCode, choose one of the `chatgpt/*` models from the picker and chat
94
+ normally. Tool calls (Doop, codegraph, etc.) round-trip through the bridge
95
+ and trigger the OpenCode approval prompt when the policy says `ask`.
96
+
97
+ ## Endpoints
98
+
99
+ | Method | Path | Purpose |
100
+ |---|---|---|
101
+ | `GET` | `/v1/models` | List the ChatGPT Plus models available in your plan |
102
+ | `POST` | `/v1/chat/completions` | OpenAI-style chat completions (streaming or JSON) |
103
+ | `GET` | `/v1/tools` | List registered MCP tools the bridge will advertise to the model |
104
+ | `POST` | `/v1/tools` | Register a list of MCP tools (used by the OpenCode plugin on session start) |
105
+ | `DELETE` | `/v1/tools` | Clear the registry |
106
+ | `GET` | `/v1/health` | Resilience metrics: requests, successes, failures, retries, circuit state |
107
+ | `GET` | `/healthz` | Liveness probe (always returns `{ ok: true }`) |
108
+
109
+ ## Tool-call flow
110
+
111
+ 1. OpenCode sends `POST /v1/chat/completions` with `messages` and optionally
112
+ `tools=[]`.
113
+ 2. The bridge translates Chat Completions into the OpenAI Responses API wire
114
+ format. If `tools` is missing, the registered MCP tools are rendered into
115
+ a Plus-style system block describing what the model can call.
116
+ 3. The bridge POSTs to `chatgpt.com/backend-api/codex/responses` with the
117
+ current access token. The response is an SSE stream of
118
+ `response.output_text.delta` / `response.function_call_arguments.delta` /
119
+ `response.completed` events.
120
+ 4. The bridge streams OpenAI-style chunks back: `delta.content` for text and
121
+ `delta.tool_calls[]` for tool calls. Tool calls carry an OpenAI-shaped
122
+ `tool_call_id` so the AI SDK can match them on the next round-trip.
123
+ 5. OpenCode runs the tool against the real MCP server and sends a follow-up
124
+ request with `role: "tool"` messages. The bridge translates those into
125
+ `function_call_output` items and re-issues.
126
+ 6. Loop until the model returns no more tool calls.
127
+
128
+ ## Tests
129
+
130
+ ```bash
131
+ npm test # smoke (server up, /healthz, /v1/models, chat)
132
+ npm run test:tools # tool transport, registry, multi-turn loop
133
+ npm run test:resilience # retry, circuit breaker, metrics (pure unit)
134
+ npm run test:smoke # live integration test against the real ChatGPT API
135
+ # (requires ~/.codex/auth.json on the machine)
136
+ ```
137
+
138
+ The `test:smoke` script is what the GitHub Actions daily cron runs to catch
139
+ wire-shape drift the day OpenAI changes something. Set
140
+ `CODEX_BRIDGE_SKIP_LIVE=1` to skip the network call in air-gapped CI.
141
+
142
+ ## CI
143
+
144
+ The GitHub Actions workflow at `.github/workflows/publish.yml` has three
145
+ jobs:
146
+
147
+ * **test**: resilience unit tests, type-check the OpenCode plugin, and
148
+ `node --check` every ESM source file.
149
+ * **smoke**: writes `CODEX_AUTH_JSON` to `~/.codex/auth.json` and runs
150
+ `test:smoke.mjs` against the real ChatGPT API. Runs on push to main and
151
+ on a daily cron. Skipped silently when the secret is missing, so a
152
+ private repo without the secret can still build.
153
+ * **publish**: on push to main, bumps the package to npm via **OpenID
154
+ Connect Trusted Publishing** (no NPM_TOKEN secret). The trusted publisher
155
+ is configured on the package itself
156
+ (`https://www.npmjs.com/package/@jaralabs/codex-bridge` → Settings →
157
+ Trusted Publishers → `jaralabs/codex-bridge` / `publish.yml`).
158
+
159
+ The publish step only uploads a new version when `package.json`'s version
160
+ differs from the latest on npm, so doc and test commits on main are no-ops.
161
+
77
162
  ## How it works
78
163
 
79
164
  ```
80
165
  ┌──────────┐ HTTP (OpenAI-compatible) ┌────────────────┐
81
166
  │ OpenCode │ ──────────────────────────────────────▶ │ codex-bridge │
82
- │ │ ◀────────────────────────────────────── │ 127.0.0.1:11434│
167
+ │ + MCP │ ◀────────────────────────────────────── │ 127.0.0.1:11434│
83
168
  └──────────┘ └────────────────┘
84
169
  │
85
- spawns ▼
170
+ HTTPS + OAuth ▼
86
171
  ┌─────────────────┐
87
- │ codex exec │
88
- │ (uses ChatGPT │
89
- │ OAuth session) │
172
+ │ chatgpt.com/ │
173
+ │ backend-api/ │
174
+ │ codex/responses │
90
175
  └─────────────────┘
91
176
  ```
92
177
 
93
- The bridge has zero state of its own. It just maps an OpenAI-style HTTP
94
- request into a Codex CLI invocation and pipes the result back. Codex handles
95
- auth, plan routing, and rate limits.
96
-
97
178
  ## License
98
179
 
99
180
  MIT.
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.1",
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",