ocx-cursor 0.1.0 → 0.3.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/README.md +227 -21
- package/bin/ocx-cursor.mjs +36 -20
- package/package.json +1 -1
- package/src/catalog.mjs +34 -3
- package/src/cursor-state.mjs +54 -19
- package/src/gateway.mjs +18 -2
- package/src/install.mjs +12 -0
- package/src/paths.mjs +3 -0
- package/src/setup.mjs +77 -0
package/README.md
CHANGED
|
@@ -3,27 +3,101 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ocx-cursor)
|
|
4
4
|
[](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml)
|
|
5
5
|
|
|
6
|
-
Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a local gateway and keeps Cursor's custom model list in sync.
|
|
6
|
+
Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a local gateway, registers the endpoint in Cursor, and keeps Cursor's custom model list in sync.
|
|
7
7
|
|
|
8
8
|
## Requirements
|
|
9
9
|
|
|
10
10
|
- macOS with Cursor installed at `/Applications/Cursor.app`
|
|
11
11
|
- Node.js 22.5 or newer
|
|
12
|
-
-
|
|
13
|
-
- An HTTPS hostname that forwards to `http://127.0.0.1:10101`
|
|
12
|
+
- A domain using Cloudflare DNS, or another HTTPS reverse proxy
|
|
14
13
|
|
|
15
14
|
Launch Cursor and sign in once before setup. Cursor creates the Safe Storage key that the installer uses to encrypt the gateway API key.
|
|
16
15
|
|
|
17
|
-
##
|
|
16
|
+
## Setup
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
The commands below use `cursor-api.example.com`. Replace it with a hostname under your own Cloudflare-managed domain.
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
### 1. Install OpenCodex
|
|
21
|
+
|
|
22
|
+
[OpenCodex](https://github.com/lidge-jun/opencodex) requires Node.js 18 or newer and bundles its own Bun runtime. This bridge requires Node.js 22.5 or newer, so install Node.js 22 or later before continuing.
|
|
23
|
+
|
|
24
|
+
Install OpenCodex without `sudo` from a user-owned Node.js installation:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install --global @bitkyc08/opencodex
|
|
28
|
+
ocx init
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The bridge service looks for `ocx` at `~/.local/bin/ocx` and `/opt/homebrew/bin/ocx`. If `command -v ocx` prints another path, link it into `~/.local/bin`:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
mkdir -p ~/.local/bin
|
|
35
|
+
if [[ "$(command -v ocx)" != "$HOME/.local/bin/ocx" ]]; then
|
|
36
|
+
ln -sf "$(command -v ocx)" ~/.local/bin/ocx
|
|
37
|
+
fi
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`ocx init` opens the interactive provider setup. You can also open the dashboard and add or sign in to a provider there:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ocx gui
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
OAuth-backed providers can also be connected from the terminal:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ocx login <provider>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Install OpenCodex as a login service, then check it:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ocx service install
|
|
56
|
+
ocx service status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
OpenCodex listens on `http://127.0.0.1:10100` by default. Confirm that its model endpoint responds:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
curl http://127.0.0.1:10100/v1/models
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If you configured OpenCodex with a service API token, include that token:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl \
|
|
69
|
+
--header "Authorization: Bearer $(cat ~/.opencodex/service-api-token)" \
|
|
70
|
+
http://127.0.0.1:10100/v1/models
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
See the [OpenCodex documentation](https://lidge-jun.github.io/opencodex/) for provider-specific login and model configuration.
|
|
74
|
+
|
|
75
|
+
### 2. Create a Cloudflare Tunnel
|
|
76
|
+
|
|
77
|
+
Cursor requires an HTTPS custom OpenAI endpoint. A [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) can publish the bridge on HTTPS without opening an inbound port on your router.
|
|
78
|
+
|
|
79
|
+
Add your domain to Cloudflare and point its nameservers to Cloudflare. Then install `cloudflared` on the Mac that runs OpenCodex and Cursor:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
brew install cloudflared
|
|
83
|
+
cloudflared tunnel login
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The login command opens Cloudflare in your browser and writes `~/.cloudflared/cert.pem`. Create a [locally-managed named tunnel](https://developers.cloudflare.com/tunnel/advanced/local-management/create-local-tunnel/):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cloudflared tunnel create ocx-cursor
|
|
90
|
+
cloudflared tunnel list
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Copy the tunnel UUID printed by the command. It also creates a credentials file named `<TUNNEL_UUID>.json` under `~/.cloudflared`.
|
|
94
|
+
|
|
95
|
+
Create `~/.cloudflared/config.yml`. Replace the UUID, macOS username, and hostname in this example:
|
|
22
96
|
|
|
23
97
|
```yaml
|
|
24
98
|
# ~/.cloudflared/config.yml
|
|
25
|
-
tunnel:
|
|
26
|
-
credentials-file: /Users/
|
|
99
|
+
tunnel: YOUR_TUNNEL_UUID
|
|
100
|
+
credentials-file: /Users/YOUR_MACOS_USERNAME/.cloudflared/YOUR_TUNNEL_UUID.json
|
|
27
101
|
|
|
28
102
|
ingress:
|
|
29
103
|
- hostname: cursor-api.example.com
|
|
@@ -31,18 +105,54 @@ ingress:
|
|
|
31
105
|
- service: http_status:404
|
|
32
106
|
```
|
|
33
107
|
|
|
34
|
-
Create the DNS
|
|
108
|
+
Create the DNS CNAME for the public hostname. The command adds the record to Cloudflare, so you do not need to create it separately in the dashboard:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
cloudflared tunnel route dns ocx-cursor cursor-api.example.com
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Validate the configuration and start the tunnel in the foreground:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cloudflared tunnel ingress validate
|
|
118
|
+
cloudflared tunnel ingress rule https://cursor-api.example.com
|
|
119
|
+
cloudflared tunnel run ocx-cursor
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The public hostname returns `502 Bad Gateway` until the bridge is installed in the next step. Keep this terminal open while testing.
|
|
123
|
+
|
|
124
|
+
For login-time startup on macOS, stop the foreground process and install the `cloudflared` launch agent:
|
|
35
125
|
|
|
36
126
|
```bash
|
|
37
|
-
cloudflared
|
|
38
|
-
cloudflared tunnel run YOUR_TUNNEL_NAME
|
|
127
|
+
cloudflared service install
|
|
39
128
|
```
|
|
40
129
|
|
|
41
|
-
|
|
130
|
+
Use `sudo cloudflared service install` only if you want a system launch daemon that starts at boot. That mode reads its configuration from `/etc/cloudflared`, not your home directory. See Cloudflare's [macOS service guide](https://developers.cloudflare.com/tunnel/advanced/local-management/as-a-service/macos/) for the required file locations.
|
|
131
|
+
|
|
132
|
+
This package does not create, modify, or remove the Cloudflare Tunnel.
|
|
42
133
|
|
|
43
|
-
|
|
134
|
+
### 3. Install the Cursor bridge
|
|
44
135
|
|
|
45
|
-
Quit Cursor, confirm that OpenCodex is running, then
|
|
136
|
+
Quit Cursor completely, confirm that OpenCodex is running, then initialize the bridge:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npx ocx-cursor init
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Enter the Cloudflare Tunnel hostname when prompted. The `https://` prefix and `/v1` suffix are optional:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
Cloudflare Tunnel URL (for example, https://cursor-api.example.com): https://cursor-api.example.com
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The installer starts the local bridge and tests two routes through Cloudflare before it changes Cursor:
|
|
149
|
+
|
|
150
|
+
- `GET /healthz` confirms that the hostname reaches this bridge.
|
|
151
|
+
- Authenticated `GET /v1/models` confirms that request headers reach the bridge and OpenCodex responds.
|
|
152
|
+
|
|
153
|
+
If either check fails, `init` stops before writing Cursor's API settings. The local bridge stays running so you can fix the tunnel and rerun the command.
|
|
154
|
+
|
|
155
|
+
For scripts and unattended setup, pass the URL directly:
|
|
46
156
|
|
|
47
157
|
```bash
|
|
48
158
|
npx ocx-cursor init \
|
|
@@ -52,10 +162,11 @@ npx ocx-cursor init \
|
|
|
52
162
|
`init` performs these actions:
|
|
53
163
|
|
|
54
164
|
1. Generates a gateway API key in `~/.opencodex/cursor-bridge/secret`.
|
|
55
|
-
2.
|
|
56
|
-
3.
|
|
57
|
-
4.
|
|
58
|
-
5.
|
|
165
|
+
2. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
|
|
166
|
+
3. Tests the Cloudflare Tunnel and OpenCodex model endpoint.
|
|
167
|
+
4. Stores the key in Cursor with macOS Safe Storage encryption.
|
|
168
|
+
5. Registers the HTTPS URL as Cursor's OpenAI base URL.
|
|
169
|
+
6. Adds active OpenCodex models to Cursor under `opencodex/*`.
|
|
59
170
|
|
|
60
171
|
The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if your shell does not include it:
|
|
61
172
|
|
|
@@ -63,20 +174,51 @@ The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PAT
|
|
|
63
174
|
export PATH="$HOME/.local/bin:$PATH"
|
|
64
175
|
```
|
|
65
176
|
|
|
66
|
-
|
|
177
|
+
Check the local gateway and public hostname before opening Cursor:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
ocx-cursor status
|
|
181
|
+
curl https://cursor-api.example.com/healthz
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The status output should show `Service: running` and `Gateway: healthy`. The public health endpoint should return JSON with `"status":"ok"`.
|
|
185
|
+
|
|
186
|
+
Open Cursor after both checks pass. Models with known reasoning controls show an effort value in the picker. Use `Shift+Command+/` to cycle it.
|
|
187
|
+
|
|
188
|
+
### Fast mode
|
|
189
|
+
|
|
190
|
+
OpenAI models whose OpenCodex catalog advertises the `priority` service tier show a Fast toggle in Cursor. Fast is off by default. When enabled, the bridge sends `service_tier: "priority"` to OpenCodex, which increases generation speed and consumes more usage.
|
|
191
|
+
|
|
192
|
+
Fast requires an OpenCodex version that preserves `service_tier` on its Chat Completions compatibility endpoint. Models without the `priority` tier, including Anthropic subscription models, do not receive the toggle.
|
|
193
|
+
|
|
194
|
+
## How requests are routed
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
Cursor
|
|
198
|
+
-> https://cursor-api.example.com/v1
|
|
199
|
+
-> Cloudflare Tunnel
|
|
200
|
+
-> OpenCodex Cursor Bridge on 127.0.0.1:10101
|
|
201
|
+
-> OpenCodex on 127.0.0.1:10100
|
|
202
|
+
-> configured provider
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The gateway API key is generated during `init` and stored in Cursor with macOS Safe Storage encryption. The bridge requires this bearer token on every `/v1/*` request. Keep the bridge bound to `127.0.0.1`; `cloudflared` can reach it without exposing port `10101` to the local network.
|
|
67
206
|
|
|
68
207
|
## Commands
|
|
69
208
|
|
|
70
209
|
| Command | Purpose |
|
|
71
210
|
| --- | --- |
|
|
72
|
-
| `ocx-cursor init --base-url URL` |
|
|
211
|
+
| `ocx-cursor init [--base-url URL]` | Install the service, prompt for and test the tunnel, configure Cursor, and sync models. Cursor must be closed. |
|
|
73
212
|
| `ocx-cursor install` | Reinstall or restart the LaunchAgent without changing Cursor's API settings. |
|
|
213
|
+
| `ocx-cursor update` | Download the latest `ocx-cursor` release from npm, reinstall the service, and sync models. |
|
|
74
214
|
| `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
|
|
75
215
|
| `ocx-cursor status` | Show service health, model count, and pending sync state. |
|
|
76
216
|
| `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
|
|
77
217
|
|
|
78
218
|
`uninstall` leaves Cursor's custom endpoint and model records in its state database.
|
|
79
219
|
|
|
220
|
+
`update` preserves the existing gateway API key and Cursor endpoint. It updates only this companion package; update OpenCodex separately with your package manager.
|
|
221
|
+
|
|
80
222
|
## Model mapping
|
|
81
223
|
|
|
82
224
|
The bridge maps source model IDs to Cursor aliases:
|
|
@@ -94,7 +236,7 @@ Cursor removes custom effort metadata from its database during startup. The Laun
|
|
|
94
236
|
|
|
95
237
|
| Variable | Default | Purpose |
|
|
96
238
|
| --- | --- | --- |
|
|
97
|
-
| `OCX_CURSOR_BASE_URL` | Stored Cursor URL |
|
|
239
|
+
| `OCX_CURSOR_BASE_URL` | Stored Cursor URL | Prompt default, or endpoint for non-interactive `init`, when `--base-url` is absent. |
|
|
98
240
|
| `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
|
|
99
241
|
| `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
|
|
100
242
|
| `OCX_CURSOR_PORT` | `10101` | Local gateway port. |
|
|
@@ -109,6 +251,70 @@ The gateway accepts these routes:
|
|
|
109
251
|
|
|
110
252
|
The gateway requires its generated bearer token on each `/v1/*` request. It binds to loopback unless you change `OCX_CURSOR_HOST`.
|
|
111
253
|
|
|
254
|
+
## Troubleshooting
|
|
255
|
+
|
|
256
|
+
### The public hostname returns 502
|
|
257
|
+
|
|
258
|
+
The tunnel is running, but it cannot reach the local bridge. Check the bridge and its logs:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
ocx-cursor status
|
|
262
|
+
tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
|
|
263
|
+
tail -n 100 ~/.opencodex/cursor-bridge/service.log
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Confirm that the tunnel ingress points to `http://127.0.0.1:10101`, then restart the bridge if needed:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
ocx-cursor install
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Cloudflare returns error 1016
|
|
273
|
+
|
|
274
|
+
The DNS record exists, but no tunnel connector is online. Check the named tunnel and start it:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
cloudflared tunnel info ocx-cursor
|
|
278
|
+
cloudflared tunnel run ocx-cursor
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
If you installed the login service, inspect it with:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
launchctl print "gui/$(id -u)/com.cloudflare.cloudflared"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Cursor returns 401
|
|
288
|
+
|
|
289
|
+
Run `init` again while Cursor is closed. It preserves the bridge key, retests the tunnel, and writes the matching encrypted value back to Cursor:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
npx ocx-cursor init
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Models or effort options are missing
|
|
296
|
+
|
|
297
|
+
Cursor must be closed before its local model database can be changed. Quit Cursor and run:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
ocx-cursor sync
|
|
301
|
+
ocx-cursor status
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
|
|
305
|
+
|
|
306
|
+
### OpenCodex models cannot be loaded
|
|
307
|
+
|
|
308
|
+
Check OpenCodex first:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
ocx status
|
|
312
|
+
ocx models --json
|
|
313
|
+
curl http://127.0.0.1:10100/v1/models
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
If `ocx models --json` works in your shell but fails in the bridge, check that `ocx` is available at `~/.local/bin/ocx` or `/opt/homebrew/bin/ocx`. The macOS LaunchAgent does not load your interactive shell profile.
|
|
317
|
+
|
|
112
318
|
## Development
|
|
113
319
|
|
|
114
320
|
```bash
|
package/bin/ocx-cursor.mjs
CHANGED
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import process from "node:process";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
|
|
6
7
|
import { cursorIsRunning } from "../src/cursor-state.mjs";
|
|
7
|
-
import { installService, prepareInstallSecret, serviceStatus, uninstallService } from "../src/install.mjs";
|
|
8
|
+
import { installService, prepareInstallSecret, serviceStatus, uninstallService, updateService } from "../src/install.mjs";
|
|
8
9
|
import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
|
|
9
10
|
import { runService } from "../src/service.mjs";
|
|
11
|
+
import { normalizeBaseUrl, testTunnel } from "../src/setup.mjs";
|
|
10
12
|
import { loadCatalogSnapshot, syncNow } from "../src/sync.mjs";
|
|
11
13
|
|
|
12
14
|
const usage = `OpenCodex Cursor Bridge
|
|
13
15
|
|
|
14
16
|
Usage:
|
|
15
|
-
ocx-cursor init --base-url <https-url>
|
|
16
|
-
|
|
17
|
+
ocx-cursor init [--base-url <https-url>]
|
|
18
|
+
Install the service, test the tunnel, configure Cursor,
|
|
19
|
+
and sync models
|
|
17
20
|
ocx-cursor install Install and start the macOS companion service
|
|
21
|
+
ocx-cursor update Install the latest companion release and restart it
|
|
18
22
|
ocx-cursor sync Sync active OpenCodex models into Cursor
|
|
19
23
|
ocx-cursor status Show service and model-sync status
|
|
20
24
|
ocx-cursor uninstall Stop and remove the companion service
|
|
@@ -26,20 +30,21 @@ function argumentValue(name) {
|
|
|
26
30
|
return index === -1 ? "" : String(process.argv[index + 1] || "");
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
async function requestedBaseUrl() {
|
|
34
|
+
const supplied = argumentValue("--base-url");
|
|
35
|
+
const fallback = cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl();
|
|
36
|
+
if (supplied) return normalizeBaseUrl(supplied);
|
|
37
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return normalizeBaseUrl(fallback);
|
|
38
|
+
|
|
39
|
+
const prompt = fallback
|
|
40
|
+
? `Cloudflare Tunnel URL [${fallback}]: `
|
|
41
|
+
: "Cloudflare Tunnel URL (for example, https://cursor-api.example.com): ";
|
|
42
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
43
|
try {
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
44
|
+
return normalizeBaseUrl((await readline.question(prompt)).trim() || fallback);
|
|
45
|
+
} finally {
|
|
46
|
+
readline.close();
|
|
38
47
|
}
|
|
39
|
-
if (url.protocol !== "https:") throw new Error("Cursor OpenAI base URL must use HTTPS");
|
|
40
|
-
url.pathname = url.pathname.replace(/\/$/, "");
|
|
41
|
-
if (!url.pathname.endsWith("/v1")) throw new Error("Cursor OpenAI base URL must end with /v1");
|
|
42
|
-
return url.toString().replace(/\/$/, "");
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
async function pendingCount() {
|
|
@@ -83,10 +88,18 @@ async function main() {
|
|
|
83
88
|
if (cursorIsRunning()) {
|
|
84
89
|
throw new Error("Quit Cursor before running ocx-cursor init so model variants and effort selectors can be applied");
|
|
85
90
|
}
|
|
86
|
-
const baseUrl =
|
|
87
|
-
argumentValue("--base-url") || cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl(),
|
|
88
|
-
);
|
|
91
|
+
const baseUrl = await requestedBaseUrl();
|
|
89
92
|
const prepared = await prepareInstallSecret();
|
|
93
|
+
const installed = await installService();
|
|
94
|
+
process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${prepared.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
|
|
95
|
+
process.stdout.write(`Testing Cloudflare Tunnel: ${new URL(baseUrl).origin}\n`);
|
|
96
|
+
let tunnel;
|
|
97
|
+
try {
|
|
98
|
+
tunnel = await testTunnel(baseUrl, prepared.secret);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(`${error.message}\nThe bridge service is running locally. Fix the tunnel and rerun ocx-cursor init.`);
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write(`Tunnel is ready (${tunnel.modelCount} models reachable).\n`);
|
|
90
103
|
const configured = await configureCursorOpenAI({
|
|
91
104
|
secret: prepared.secret,
|
|
92
105
|
baseUrl,
|
|
@@ -94,11 +107,14 @@ async function main() {
|
|
|
94
107
|
process.stdout.write(configured.changed
|
|
95
108
|
? `Configured Cursor OpenAI endpoint: ${baseUrl}\nBackup: ${configured.backupPath}\n`
|
|
96
109
|
: `Cursor OpenAI endpoint is already configured: ${baseUrl}\n`);
|
|
97
|
-
const installed = await installService();
|
|
98
|
-
process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
|
|
99
110
|
printSync(await syncNow());
|
|
100
111
|
return;
|
|
101
112
|
}
|
|
113
|
+
if (command === "update") {
|
|
114
|
+
process.stdout.write("Updating OpenCodex Cursor Bridge from npm...\n");
|
|
115
|
+
updateService();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
102
118
|
if (command === "sync") {
|
|
103
119
|
printSync(await syncNow());
|
|
104
120
|
return;
|
package/package.json
CHANGED
package/src/catalog.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
codexConfigFile,
|
|
8
|
+
defaultCodexCatalogFile,
|
|
9
|
+
managedPrefix,
|
|
10
|
+
opencodexConfigFile,
|
|
11
|
+
opencodexServiceTokenFile,
|
|
12
|
+
} from "./paths.mjs";
|
|
6
13
|
|
|
7
14
|
export const allowedEfforts = ["low", "medium", "high", "xhigh", "max"];
|
|
8
15
|
export const effortLabels = {
|
|
@@ -44,7 +51,29 @@ export function sanitizeEfforts(sourceId, configured) {
|
|
|
44
51
|
return allowedEfforts.filter((effort) => values.includes(effort));
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
export function
|
|
54
|
+
export function configuredCodexCatalogFile(configFile = codexConfigFile) {
|
|
55
|
+
try {
|
|
56
|
+
const content = readFileSync(configFile, "utf8");
|
|
57
|
+
const match = content.match(/^\s*model_catalog_json\s*=\s*("(?:[^"\\]|\\.)*")\s*$/m);
|
|
58
|
+
if (match) return resolve(dirname(configFile), JSON.parse(match[1]));
|
|
59
|
+
} catch {}
|
|
60
|
+
return defaultCodexCatalogFile;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function priorityModelIds(catalogFile = configuredCodexCatalogFile()) {
|
|
64
|
+
try {
|
|
65
|
+
const payload = JSON.parse(readFileSync(catalogFile, "utf8"));
|
|
66
|
+
return new Set(payload.models
|
|
67
|
+
.filter((model) => Array.isArray(model?.service_tiers)
|
|
68
|
+
&& model.service_tiers.some((tier) => tier?.id === "priority"))
|
|
69
|
+
.map((model) => model.slug || model.id)
|
|
70
|
+
.filter((id) => typeof id === "string"));
|
|
71
|
+
} catch {
|
|
72
|
+
return new Set();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeActiveCatalog(configured, active, fastModelIds = new Set()) {
|
|
48
77
|
const configuredById = new Map(configured
|
|
49
78
|
.filter((model) => typeof model.provider === "string" && typeof model.model === "string")
|
|
50
79
|
.map((model) => [`${model.provider}/${model.model}`, model]));
|
|
@@ -69,6 +98,7 @@ export function normalizeActiveCatalog(configured, active) {
|
|
|
69
98
|
maxOutputTokens: model.capabilities?.max_output_tokens,
|
|
70
99
|
inputModalities,
|
|
71
100
|
reasoningEfforts: sanitizeEfforts(model.id, configuredModel?.reasoningEfforts ?? model.capabilities?.reasoning_effort),
|
|
101
|
+
supportsFast: fastModelIds.has(model.id),
|
|
72
102
|
});
|
|
73
103
|
}
|
|
74
104
|
|
|
@@ -113,5 +143,6 @@ export async function buildActiveCatalog(options = {}) {
|
|
|
113
143
|
return normalizeActiveCatalog(
|
|
114
144
|
configuredModels(options.ocxBin),
|
|
115
145
|
await activeModels(options.fetchImpl),
|
|
146
|
+
options.fastModelIds || priorityModelIds(options.codexCatalogFile),
|
|
116
147
|
);
|
|
117
148
|
}
|
package/src/cursor-state.mjs
CHANGED
|
@@ -42,26 +42,55 @@ function effortDefinition(model) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function
|
|
45
|
+
function fastDefinition() {
|
|
46
|
+
return {
|
|
47
|
+
id: "fast",
|
|
48
|
+
name: "Fast",
|
|
49
|
+
markdownTooltip: "1.5x speed with increased usage.",
|
|
50
|
+
parameterType: {
|
|
51
|
+
booleanParameter: {
|
|
52
|
+
values: [
|
|
53
|
+
{ value: "false" },
|
|
54
|
+
{ value: "true", displayName: "Fast", increasesModelCost: true },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
isCycleableByHotkey: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function modelVariants(model) {
|
|
46
63
|
const parameterId = parameterIdFor(model);
|
|
47
64
|
const selectedDefault = defaultEffort(model);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
const efforts = model.reasoningEfforts.length > 0 ? model.reasoningEfforts : [null];
|
|
66
|
+
const fastValues = model.supportsFast ? ["false", "true"] : [null];
|
|
67
|
+
return efforts.flatMap((effort) => fastValues.map((fast) => {
|
|
68
|
+
const labels = [effort ? effortLabels[effort] : null, fast === "true" ? "Fast" : null].filter(Boolean);
|
|
69
|
+
const displayName = labels.length > 0
|
|
70
|
+
? `${model.alias} <span style="color: var(--cursor-text-tertiary);">${labels.join(" ")}</span>`
|
|
71
|
+
: model.alias;
|
|
72
|
+
const isDefault = (effort === null || effort === selectedDefault) && fast !== "true";
|
|
73
|
+
const parameters = [
|
|
74
|
+
...(effort ? [{ id: parameterId, value: effort }] : []),
|
|
75
|
+
...(fast ? [{ id: "fast", value: fast }] : []),
|
|
76
|
+
];
|
|
77
|
+
const suffix = [effort, fast === "true" ? "fast" : null].filter(Boolean).join("-");
|
|
51
78
|
return {
|
|
52
|
-
parameterValues:
|
|
79
|
+
parameterValues: parameters,
|
|
53
80
|
displayName,
|
|
54
81
|
displayNameOutsidePicker: displayName,
|
|
55
82
|
isMaxMode: false,
|
|
56
83
|
...(isDefault ? { isDefaultMaxConfig: true, isDefaultNonMaxConfig: true } : {}),
|
|
57
|
-
variantStringRepresentation: `${model.alias}[${
|
|
58
|
-
legacySlug: `${model.alias}-${
|
|
84
|
+
variantStringRepresentation: `${model.alias}[${parameters.map(({ id, value }) => `${id}=${value}`).join(",")}]`,
|
|
85
|
+
legacySlug: suffix ? `${model.alias}-${suffix}` : model.alias,
|
|
59
86
|
};
|
|
60
|
-
});
|
|
87
|
+
}));
|
|
61
88
|
}
|
|
62
89
|
|
|
63
90
|
export function cursorModel(model) {
|
|
64
91
|
const hasEffort = model.reasoningEfforts.length > 0;
|
|
92
|
+
const hasVariants = hasEffort || model.supportsFast;
|
|
93
|
+
const variants = hasVariants ? modelVariants(model) : [];
|
|
65
94
|
return {
|
|
66
95
|
name: model.alias,
|
|
67
96
|
defaultOn: false,
|
|
@@ -80,9 +109,12 @@ export function cursorModel(model) {
|
|
|
80
109
|
idAliases: [],
|
|
81
110
|
namedModelSectionIndex: 1,
|
|
82
111
|
cloudAgentEffortModes: [],
|
|
83
|
-
parameterDefinitions:
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
parameterDefinitions: [
|
|
113
|
+
...(hasEffort ? [effortDefinition(model)] : []),
|
|
114
|
+
...(model.supportsFast ? [fastDefinition()] : []),
|
|
115
|
+
],
|
|
116
|
+
variants,
|
|
117
|
+
legacySlugs: variants.map(({ legacySlug }) => legacySlug),
|
|
86
118
|
modelPickerBadges: [],
|
|
87
119
|
};
|
|
88
120
|
}
|
|
@@ -93,15 +125,18 @@ function syncSelectedModel(state, catalog) {
|
|
|
93
125
|
const byAlias = new Map(catalog.map((model) => [model.alias, model]));
|
|
94
126
|
for (const selected of composer.selectedModels) {
|
|
95
127
|
const model = byAlias.get(selected.modelId);
|
|
96
|
-
if (!model || model.reasoningEfforts.length === 0) continue;
|
|
128
|
+
if (!model || (model.reasoningEfforts.length === 0 && !model.supportsFast)) continue;
|
|
97
129
|
const parameterId = parameterIdFor(model);
|
|
98
|
-
const current = Array.isArray(selected.parameters)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
selected.parameters = [
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
130
|
+
const current = Array.isArray(selected.parameters) ? selected.parameters : [];
|
|
131
|
+
const effort = current.find(({ id }) => id === parameterId)?.value;
|
|
132
|
+
const fast = current.find(({ id }) => id === "fast")?.value;
|
|
133
|
+
selected.parameters = [
|
|
134
|
+
...(model.reasoningEfforts.length > 0 ? [{
|
|
135
|
+
id: parameterId,
|
|
136
|
+
value: model.reasoningEfforts.includes(effort) ? effort : defaultEffort(model),
|
|
137
|
+
}] : []),
|
|
138
|
+
...(model.supportsFast ? [{ id: "fast", value: fast === "true" ? "true" : "false" }] : []),
|
|
139
|
+
];
|
|
105
140
|
}
|
|
106
141
|
}
|
|
107
142
|
|
package/src/gateway.mjs
CHANGED
|
@@ -29,6 +29,13 @@ function suppliedEffort(payload, variantText) {
|
|
|
29
29
|
|| payload.reasoningEffort;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function suppliedFast(variantText) {
|
|
33
|
+
return variantText
|
|
34
|
+
?.split(",")
|
|
35
|
+
.map((value) => value.split("=", 2))
|
|
36
|
+
.find(([key]) => key === "fast")?.[1];
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export function rewriteModelAliasBody(body, catalog) {
|
|
33
40
|
if (!body?.length) return body;
|
|
34
41
|
let payload;
|
|
@@ -42,10 +49,16 @@ export function rewriteModelAliasBody(body, catalog) {
|
|
|
42
49
|
const variant = /^(opencodex\/.+?)\[([^\]]+)\]$/.exec(payload.model);
|
|
43
50
|
let alias = variant?.[1] || payload.model;
|
|
44
51
|
let effort = suppliedEffort(payload, variant?.[2]);
|
|
52
|
+
let fast = suppliedFast(variant?.[2]);
|
|
45
53
|
if (!allowedEfforts.includes(effort)) {
|
|
46
|
-
const legacy = catalog.find((model) => model.reasoningEfforts?.some((value) =>
|
|
54
|
+
const legacy = catalog.find((model) => model.reasoningEfforts?.some((value) => (
|
|
55
|
+
payload.model === `${model.alias}-${value}` || payload.model === `${model.alias}-${value}-fast`
|
|
56
|
+
)));
|
|
47
57
|
if (legacy) {
|
|
48
|
-
effort = legacy.reasoningEfforts.find((value) =>
|
|
58
|
+
effort = legacy.reasoningEfforts.find((value) => (
|
|
59
|
+
payload.model === `${legacy.alias}-${value}` || payload.model === `${legacy.alias}-${value}-fast`
|
|
60
|
+
));
|
|
61
|
+
fast = payload.model.endsWith("-fast") ? "true" : "false";
|
|
49
62
|
alias = legacy.alias;
|
|
50
63
|
}
|
|
51
64
|
}
|
|
@@ -55,6 +68,8 @@ export function rewriteModelAliasBody(body, catalog) {
|
|
|
55
68
|
payload.model = catalogModel?.sourceId
|
|
56
69
|
|| (fallbackSourceId.startsWith("claude-") ? `anthropic/${fallbackSourceId}` : fallbackSourceId);
|
|
57
70
|
if (allowedEfforts.includes(effort)) payload.reasoning_effort = effort;
|
|
71
|
+
if (catalogModel?.supportsFast && fast === "true") payload.service_tier = "priority";
|
|
72
|
+
if (catalogModel?.supportsFast && fast === "false") delete payload.service_tier;
|
|
58
73
|
delete payload.reasoningEffort;
|
|
59
74
|
return Buffer.from(JSON.stringify(payload));
|
|
60
75
|
}
|
|
@@ -77,6 +92,7 @@ export function enrichModelList(active, catalog) {
|
|
|
77
92
|
supports_tool_use: true,
|
|
78
93
|
supports_streaming: true,
|
|
79
94
|
supports_reasoning: model.reasoningEfforts.length > 0,
|
|
95
|
+
supports_fast: model.supportsFast,
|
|
80
96
|
supports_vision: model.inputModalities.includes("image"),
|
|
81
97
|
reasoning_effort: model.reasoningEfforts,
|
|
82
98
|
},
|
package/src/install.mjs
CHANGED
|
@@ -176,6 +176,18 @@ export async function installService() {
|
|
|
176
176
|
return { installRoot, launchAgentFile, cliLinkFile, secretStatus };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
export function updateService(options = {}) {
|
|
180
|
+
const execute = options.execFileSync || execFileSync;
|
|
181
|
+
execute(options.npmCommand || "npm", [
|
|
182
|
+
"exec",
|
|
183
|
+
"--yes",
|
|
184
|
+
"--package=ocx-cursor@latest",
|
|
185
|
+
"--",
|
|
186
|
+
"ocx-cursor",
|
|
187
|
+
"install",
|
|
188
|
+
], { stdio: "inherit" });
|
|
189
|
+
}
|
|
190
|
+
|
|
179
191
|
export async function uninstallService() {
|
|
180
192
|
bootout(serviceLabel);
|
|
181
193
|
await rm(launchAgentFile, { force: true });
|
package/src/paths.mjs
CHANGED
|
@@ -14,6 +14,9 @@ export const stderrFile = join(installRoot, "service.error.log");
|
|
|
14
14
|
export const launchAgentFile = join(homedir(), "Library", "LaunchAgents", `${serviceLabel}.plist`);
|
|
15
15
|
export const legacyLaunchAgentFile = join(homedir(), "Library", "LaunchAgents", `${legacyServiceLabel}.plist`);
|
|
16
16
|
export const cursorDatabaseFile = join(homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
17
|
+
export const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
18
|
+
export const codexConfigFile = join(codexHome, "config.toml");
|
|
19
|
+
export const defaultCodexCatalogFile = join(codexHome, "opencodex-catalog.json");
|
|
17
20
|
export const opencodexConfigFile = join(homedir(), ".opencodex", "config.json");
|
|
18
21
|
export const opencodexServiceTokenFile = join(homedir(), ".opencodex", "service-api-token");
|
|
19
22
|
export const gatewayPort = Number(process.env.OCX_CURSOR_PORT || "10101");
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function normalizeBaseUrl(value) {
|
|
2
|
+
if (!value) {
|
|
3
|
+
throw new Error("Enter your Cloudflare Tunnel URL or pass --base-url https://your-domain.example/v1");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const candidate = value.includes("://") ? value : `https://${value}`;
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(candidate);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Invalid Cloudflare Tunnel URL: ${value}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (url.protocol !== "https:") throw new Error("Cloudflare Tunnel URL must use HTTPS");
|
|
15
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
16
|
+
throw new Error("Cloudflare Tunnel URL cannot contain credentials, a query, or a fragment");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
20
|
+
if (pathname && pathname !== "/v1") {
|
|
21
|
+
throw new Error("Cloudflare Tunnel URL must be a hostname or end with /v1");
|
|
22
|
+
}
|
|
23
|
+
url.pathname = "/v1";
|
|
24
|
+
return url.toString().replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function checkedJson(fetchImpl, url, options, label) {
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
response = await fetchImpl(url, {
|
|
31
|
+
...options,
|
|
32
|
+
signal: options?.signal || AbortSignal.timeout(10_000),
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`${label} failed for ${url}: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!response.ok) throw new Error(`${label} returned HTTP ${response.status} for ${url}`);
|
|
39
|
+
try {
|
|
40
|
+
return await response.json();
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`${label} returned invalid JSON from ${url}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function testTunnel(baseUrl, secret, options = {}) {
|
|
47
|
+
baseUrl = normalizeBaseUrl(baseUrl);
|
|
48
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
49
|
+
const origin = new URL(baseUrl).origin;
|
|
50
|
+
const attempts = options.healthAttempts || 10;
|
|
51
|
+
let health;
|
|
52
|
+
let healthError;
|
|
53
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
54
|
+
try {
|
|
55
|
+
health = await checkedJson(fetchImpl, `${origin}/healthz`, {}, "Tunnel health check");
|
|
56
|
+
break;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
healthError = error;
|
|
59
|
+
if (attempt + 1 < attempts) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, options.retryDelayMs ?? 500));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!health) throw healthError;
|
|
65
|
+
if (health?.service !== "opencodex-cursor-bridge" || health?.status !== "ok") {
|
|
66
|
+
throw new Error(`Tunnel health check reached an unexpected service at ${origin}/healthz`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const models = await checkedJson(fetchImpl, `${baseUrl}/models`, {
|
|
70
|
+
headers: { authorization: `Bearer ${secret}` },
|
|
71
|
+
}, "Tunnel model check");
|
|
72
|
+
if (!Array.isArray(models?.data)) {
|
|
73
|
+
throw new Error(`Tunnel model check returned an invalid catalog from ${baseUrl}/models`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { health, modelCount: models.data.length };
|
|
77
|
+
}
|