@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 +135 -54
- package/lib/auth.mjs +79 -0
- package/lib/capabilities.mjs +183 -0
- package/lib/resilience.mjs +182 -0
- package/lib/server.mjs +89 -143
- package/package.json +5 -3
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
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
balance.
|
|
10
|
-
|
|
11
|
-
|
|
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 /
|
|
17
|
-
App today with the subscription. `codex-bridge` exposes the same models
|
|
18
|
-
an OpenAI-compatible HTTP surface so OpenCode (and any other OpenAI
|
|
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
|
|
28
|
-
once with `codex login` on a machine with the ChatGPT Business / Team / Plus
|
|
29
|
-
session.
|
|
41
|
+
Requires:
|
|
30
42
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
│
|
|
167
|
+
│ + MCP │ ◀────────────────────────────────────── │ 127.0.0.1:11434│
|
|
83
168
|
└──────────┘ └────────────────┘
|
|
84
169
|
│
|
|
85
|
-
|
|
170
|
+
HTTPS + OAuth ▼
|
|
86
171
|
┌─────────────────┐
|
|
87
|
-
│
|
|
88
|
-
│
|
|
89
|
-
│
|
|
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.
|
|
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/
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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 {
|
|
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
|
-
|
|
182
|
+
authorization: `Bearer ${auth.access_token}`,
|
|
266
183
|
"content-type": "application/json",
|
|
267
|
-
|
|
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", {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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.
|
|
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();
|
|
230
|
+
const toolCalls = new Map();
|
|
304
231
|
let usage = null;
|
|
305
|
-
let currentItem = null;
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
"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": ">=
|
|
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",
|