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 CHANGED
@@ -3,27 +3,101 @@
3
3
  [![npm version](https://img.shields.io/npm/v/ocx-cursor.svg)](https://www.npmjs.com/package/ocx-cursor)
4
4
  [![CI](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml/badge.svg)](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
- - OpenCodex installed, signed in, and running
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
- ## Set up an HTTPS endpoint
16
+ ## Setup
18
17
 
19
- Cursor's custom OpenAI endpoint must use HTTPS. Point a tunnel or reverse proxy at the local gateway on port `10101`.
18
+ The commands below use `cursor-api.example.com`. Replace it with a hostname under your own Cloudflare-managed domain.
20
19
 
21
- This Cloudflare Tunnel config maps `cursor-api.example.com` to the gateway:
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: YOUR_TUNNEL_ID
26
- credentials-file: /Users/YOU/.cloudflared/YOUR_TUNNEL_ID.json
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 route and run the tunnel:
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 tunnel route dns YOUR_TUNNEL_NAME cursor-api.example.com
38
- cloudflared tunnel run YOUR_TUNNEL_NAME
127
+ cloudflared service install
39
128
  ```
40
129
 
41
- The package does not install or manage the tunnel.
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
- ## Install
134
+ ### 3. Install the Cursor bridge
44
135
 
45
- Quit Cursor, confirm that OpenCodex is running, then run:
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. Stores the key in Cursor with macOS Safe Storage encryption.
56
- 3. Registers the HTTPS URL as Cursor's OpenAI base URL.
57
- 4. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
58
- 5. Adds active OpenCodex models to Cursor under `opencodex/*`.
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
- Open Cursor after `init` finishes. Models with known reasoning controls show an effort value in the picker. Use `Shift+Command+/` to cycle it.
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` | Configure Cursor, install the service, and sync models. Cursor must be closed. |
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 | HTTPS endpoint used by `init` when `--base-url` is absent. |
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
@@ -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
- Configure Cursor, install the service, and sync models
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 normalizedBaseUrl(value) {
30
- if (!value) {
31
- throw new Error("Pass --base-url https://your-domain.example/v1 or set OCX_CURSOR_BASE_URL");
32
- }
33
- let url;
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
- url = new URL(value);
36
- } catch {
37
- throw new Error(`Invalid Cursor OpenAI base URL: ${value}`);
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 = normalizedBaseUrl(
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCodex companion service for Cursor custom models",
5
5
  "keywords": [
6
6
  "opencodex",
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 { managedPrefix, opencodexConfigFile, opencodexServiceTokenFile } from "./paths.mjs";
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 normalizeActiveCatalog(configured, active) {
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
  }
@@ -42,26 +42,55 @@ function effortDefinition(model) {
42
42
  };
43
43
  }
44
44
 
45
- function effortVariants(model) {
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
- return model.reasoningEfforts.map((effort) => {
49
- const displayName = `${model.alias} <span style="color: var(--cursor-text-tertiary);">${effortLabels[effort]}</span>`;
50
- const isDefault = effort === selectedDefault;
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: [{ id: parameterId, value: effort }],
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}[${parameterId}=${effort}]`,
58
- legacySlug: `${model.alias}-${effort}`,
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: hasEffort ? [effortDefinition(model)] : [],
84
- variants: hasEffort ? effortVariants(model) : [],
85
- legacySlugs: model.reasoningEfforts.map((effort) => `${model.alias}-${effort}`),
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
- ? selected.parameters.find(({ id }) => id === parameterId)?.value
100
- : undefined;
101
- selected.parameters = [{
102
- id: parameterId,
103
- value: model.reasoningEfforts.includes(current) ? current : defaultEffort(model),
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) => payload.model === `${model.alias}-${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) => payload.model === `${legacy.alias}-${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
+ }