ocx-cursor 0.5.0 → 0.5.2

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,244 +3,116 @@
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 loopback-only gateway, registers it in Cursor, and keeps Cursor's custom model list in sync.
6
+ Use your active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through a local OpenAI-compatible gateway.
7
7
 
8
- ## Requirements
9
-
10
- - macOS with Cursor installed at `/Applications/Cursor.app`
11
- - Node.js 22.5 or newer
12
- - OpenCodex installed and configured
13
-
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.
15
-
16
- ## Setup
8
+ > [!WARNING]
9
+ > This package edits private JavaScript files inside `Cursor.app`. The changes invalidate Cursor's vendor signature, so Cursor may report that the installation is corrupt. The installer saves original files under `~/.opencodex/cursor-bridge/cursor-app-backups`. Reinstall Cursor to restore a signed app.
17
10
 
18
- ### 1. Install OpenCodex
11
+ ## Quick start
19
12
 
20
- [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.
13
+ You need macOS, Node.js 22.5 or newer, and Cursor at `/Applications/Cursor.app`.
21
14
 
22
- Install OpenCodex without `sudo` from a user-owned Node.js installation:
15
+ Install and configure OpenCodex:
23
16
 
24
17
  ```bash
25
18
  npm install --global @bitkyc08/opencodex
26
19
  ocx init
27
- ```
28
-
29
- 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`:
30
-
31
- ```bash
32
- mkdir -p ~/.local/bin
33
- if [[ "$(command -v ocx)" != "$HOME/.local/bin/ocx" ]]; then
34
- ln -sf "$(command -v ocx)" ~/.local/bin/ocx
35
- fi
36
- ```
37
-
38
- `ocx init` opens the interactive provider setup. You can also configure providers through the dashboard or terminal:
39
-
40
- ```bash
41
- ocx gui
42
- ocx login <provider>
43
- ```
44
-
45
- Install OpenCodex as a login service, then verify it:
46
-
47
- ```bash
48
20
  ocx service install
49
21
  ocx service status
50
- curl http://127.0.0.1:10100/v1/models
51
22
  ```
52
23
 
53
- If you configured OpenCodex with a service API token, include it in the request:
24
+ Check the configured models:
54
25
 
55
26
  ```bash
56
- curl \
57
- --header "Authorization: Bearer $(cat ~/.opencodex/service-api-token)" \
58
- http://127.0.0.1:10100/v1/models
27
+ ocx models --json
59
28
  ```
60
29
 
61
- See the [OpenCodex documentation](https://lidge-jun.github.io/opencodex/) for provider-specific setup.
62
-
63
- ### 2. Install the Cursor bridge
64
-
65
- Quit Cursor completely, confirm that OpenCodex is running, then initialize the bridge:
30
+ Launch Cursor and sign in once, then quit it. Run the bridge installer:
66
31
 
67
32
  ```bash
68
33
  npx ocx-cursor init
34
+ ~/.local/bin/ocx-cursor status
69
35
  ```
70
36
 
71
- No tunnel or public domain is required. `init` uses `http://127.0.0.1:10101/v1` and performs these actions:
72
-
73
- 1. Generates a gateway API key in `~/.opencodex/cursor-bridge/secret`.
74
- 2. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
75
- 3. Tests the local gateway and OpenCodex model endpoint.
76
- 4. Stores the key in Cursor with macOS Safe Storage encryption.
77
- 5. Registers the local gateway as Cursor's OpenAI base URL.
78
- 6. Adds active OpenCodex models to Cursor under `opencodex/*`.
79
- 7. Enables Cursor's local-agent mode and installs the model metadata patches described below.
80
-
81
- `init` patches ten JavaScript files inside `Cursor.app`:
82
-
83
- - Eight Cursor bundles get the local-mode build flag.
84
- - The Desktop and Glass workbench bundles retain OpenCodex display names, effort choices, and Fast metadata when Cursor refreshes its server catalog.
85
- - Both local-agent runtimes display names such as `GPT 5.6 Sol` while keeping `opencodex/gpt-5.6-sol` as the request ID.
37
+ `init` registers `http://127.0.0.1:10101/v1` in Cursor. You do not need a domain or tunnel. Open Cursor after the status command reports `Service: running` and `Gateway: healthy`.
86
38
 
87
- Original files are copied to `~/.opencodex/cursor-bridge/cursor-app-backups` before they are changed. The LaunchAgent watches the same files and reapplies the patches after a Cursor update, once Cursor has quit.
88
-
89
- > [!WARNING]
90
- > Patching `Cursor.app` invalidates its vendor code signature. Cursor may show “Your Cursor installation appears to be corrupt.” This is expected for this integration. Reinstall Cursor to restore an unmodified, vendor-signed app.
91
-
92
- The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if needed:
39
+ Add the installed command to your shell path if needed:
93
40
 
94
41
  ```bash
95
42
  export PATH="$HOME/.local/bin:$PATH"
96
43
  ```
97
44
 
98
- Check the service before opening Cursor:
99
-
100
- ```bash
101
- ocx-cursor status
102
- curl http://127.0.0.1:10101/healthz
103
- ```
104
-
105
- The status output should show `Service: running` and `Gateway: healthy`. Open Cursor after both checks pass.
106
-
107
- To use an explicit HTTPS reverse proxy instead, pass its URL directly. This is optional and not used by the default setup:
108
-
109
- ```bash
110
- npx ocx-cursor init --base-url https://cursor-api.example.com/v1
111
- ```
45
+ ## What init changes
112
46
 
113
- ### Fast mode
114
-
115
- 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.
116
-
117
- 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.
47
+ | Area | Change |
48
+ | --- | --- |
49
+ | Cursor settings | Stores the gateway key with macOS Safe Storage and sets the local base URL. |
50
+ | Cursor model state | Adds active models under `opencodex/*` with names, effort choices, and Fast metadata. |
51
+ | `Cursor.app` | Enables local-agent mode in eight bundles and patches two local-agent runtimes. |
52
+ | Login service | Installs `com.opencodex.cursor-bridge`, refreshes models, and reapplies patches after Cursor updates. |
118
53
 
119
- ## How requests are routed
54
+ Requests stay on loopback until OpenCodex sends them to your configured provider:
120
55
 
121
56
  ```text
122
- Cursor
123
- -> OpenCodex Cursor Bridge on http://127.0.0.1:10101/v1
124
- -> OpenCodex on http://127.0.0.1:10100
125
- -> configured provider
57
+ Cursor -> 127.0.0.1:10101 -> OpenCodex on 127.0.0.1:10100 -> provider
126
58
  ```
127
59
 
128
- 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 and binds to `127.0.0.1` by default.
129
-
130
- ## Commands
131
-
132
- | Command | Purpose |
133
- | --- | --- |
134
- | `ocx-cursor init [--base-url URL]` | Install the service, test the endpoint, configure Cursor, and sync models. Cursor must be closed. |
135
- | `ocx-cursor install` | Reinstall the LaunchAgent and verify app patches, or queue them until Cursor quits. |
136
- | `ocx-cursor update` | Download the latest release from npm, reinstall the service, verify patches, and sync models. |
137
- | `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
138
- | `ocx-cursor launch` | Experimentally launch Cursor with live effort and Fast metadata injection. Keep the command running. |
139
- | `ocx-cursor status` | Show service health, model count, and pending sync state. |
140
- | `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
141
-
142
- `uninstall` leaves Cursor's custom endpoint, model records, and app patches in place. Reinstall Cursor if you want to restore its original signed files.
60
+ The gateway requires its generated bearer token on every `/v1/*` request. It binds to `127.0.0.1` unless you change `OCX_CURSOR_HOST`.
143
61
 
144
- `update` preserves the existing gateway API key and Cursor endpoint. If Cursor is running, app patches and model sync are queued until it quits. The command updates only this companion package; update OpenCodex separately with your package manager.
62
+ Active OpenCodex `cursor/*` models are exposed under `opencodex/cursor/*`. The provider is experimental; the bridge cancels its upstream request when Cursor ends the matching client request.
145
63
 
146
- ## Model mapping
64
+ ## Model behavior
147
65
 
148
- The bridge maps source model IDs to Cursor aliases:
66
+ The bridge reads OpenCodex's active `/v1/models` response and creates stable Cursor aliases:
149
67
 
150
68
  ```text
151
- anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
152
- gpt-5.6-sol -> opencodex/gpt-5.6-sol
69
+ anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
70
+ gpt-5.6-sol -> opencodex/gpt-5.6-sol
153
71
  ```
154
72
 
155
- The catalog includes every model returned by OpenCodex's active `/v1/models` endpoint, including models from the `cursor/*` provider. Cursor-backed aliases use the `opencodex/cursor/*` prefix so they remain distinct from Cursor's built-in entries.
156
-
157
- Cursor removes custom effort metadata from its database during startup. The companion LaunchAgent patches the Desktop and Glass catalog storage steps so `opencodex/*` display names, effort, and Fast metadata survive server refreshes. It monitors the installed bundles and local-agent runtimes, then reapplies their patches when Cursor replaces the app during an update.
158
-
159
- `init`, `install`, and `update` also patch Cursor's local model runtimes so provider refreshes keep human-readable names such as `GPT 5.6 Sol` while requests continue using stable `opencodex/*` model IDs.
73
+ Cursor shows readable names such as `GPT 5.6 Sol`. Reasoning models receive an effort selector. Models with OpenCodex's `priority` service tier receive a Fast toggle.
160
74
 
161
- Cursor sends some built-in function tools with `strict: true` even when their JSON schema is not valid for strict mode. The gateway changes that flag to `false` for OpenCodex requests, preventing errors such as `additionalProperties is required to be supplied and to be false` without changing tool arguments.
75
+ Cursor can send `strict: true` with function schemas that fail strict validation. The gateway changes the flag to `false` before forwarding those requests. It leaves tool names, descriptions, and arguments intact.
162
76
 
163
- ### Experimental live model metadata
77
+ ## Commands and configuration
164
78
 
165
- `ocx-cursor launch` starts Cursor with a random loopback-only debugging port and keeps `opencodex/*` effort and Fast metadata in the live model catalog:
166
-
167
- ```bash
168
- ocx-cursor launch
169
- ```
170
-
171
- Keep the command running for the lifetime of Cursor. This does not modify `Cursor.app`, but it depends on Cursor's private workbench code and supports only known Cursor builds.
172
-
173
- ## Configuration
79
+ | Command | Purpose |
80
+ | --- | --- |
81
+ | `ocx-cursor init` | Install the service, configure Cursor, patch the app, and sync models. Cursor must be closed. |
82
+ | `ocx-cursor install` | Reinstall the service and check app patches. Running Cursor defers app changes until exit. |
83
+ | `ocx-cursor update` | Install the newest npm release and run `install`. |
84
+ | `ocx-cursor sync` | Refresh the active model list. |
85
+ | `ocx-cursor status` | Show service health and pending model sync. |
86
+ | `ocx-cursor uninstall` | Remove the service, command link, and bridge state. |
174
87
 
175
- | Variable | Default | Purpose |
176
- | --- | --- | --- |
177
- | `OCX_CURSOR_BASE_URL` | `http://127.0.0.1:10101/v1` | Endpoint used by `init` when `--base-url` is absent. |
178
- | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
179
- | `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
180
- | `OCX_CURSOR_PORT` | `10101` | Local gateway port. Also changes the default Cursor endpoint. |
181
- | `OCX_BIN` | `~/.local/bin/ocx` | OpenCodex CLI path. |
88
+ `uninstall` does not restore Cursor settings or patched app files. Reinstall Cursor if you want a clean vendor build.
182
89
 
183
- The gateway accepts these routes:
90
+ | Variable | Default |
91
+ | --- | --- |
92
+ | `OCX_CURSOR_BASE_URL` | `http://127.0.0.1:10101/v1` |
93
+ | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` |
94
+ | `OCX_CURSOR_HOST` | `127.0.0.1` |
95
+ | `OCX_CURSOR_PORT` | `10101` |
96
+ | `OCX_BIN` | `~/.local/bin/ocx` |
184
97
 
185
- - `GET /v1/models`
186
- - `POST /v1/chat/completions`
187
- - `POST /v1/responses` and `/v1/responses/compact`
188
- - `POST /v1/messages`
98
+ The bridge reads an OpenCodex service token from `~/.opencodex/service-api-token` when that file exists.
189
99
 
190
100
  ## Troubleshooting
191
101
 
192
- ### Cursor returns 401
193
-
194
- Quit Cursor and run `init` again. It preserves the bridge key, tests the local endpoint, and writes the matching encrypted value back to Cursor:
195
-
196
- ```bash
197
- npx ocx-cursor init
198
- ```
199
-
200
- ### The local gateway is unavailable
201
-
202
- Check the service and logs, then reinstall it if needed:
203
-
204
- ```bash
205
- ocx-cursor status
206
- tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
207
- tail -n 100 ~/.opencodex/cursor-bridge/service.log
208
- ocx-cursor install
209
- ```
210
-
211
- ### Models or effort options are missing
212
-
213
- Quit Cursor and run:
214
-
215
- ```bash
216
- ocx-cursor sync
217
- ocx-cursor status
218
- ```
219
-
220
- If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
221
-
222
- ### Patches are missing after a Cursor update
223
-
224
- Quit every Cursor window and wait for its Helper processes to stop. The LaunchAgent detects replaced app files and reapplies local mode, metadata, and display-name patches. Check the result with:
102
+ | Symptom | Fix |
103
+ | --- | --- |
104
+ | Cursor returns 401 | Quit Cursor and rerun `npx ocx-cursor init`. |
105
+ | Models or effort choices are missing | Quit Cursor, run `ocx-cursor sync`, then check `ocx-cursor status`. |
106
+ | A Cursor update removed the patches | Quit Cursor and run `ocx-cursor install`. |
107
+ | The gateway is unavailable | Run `ocx-cursor status` and inspect the logs below. |
108
+ | The bridge cannot find `ocx` | Run `ln -sf "$(command -v ocx)" ~/.local/bin/ocx`. |
225
109
 
226
110
  ```bash
227
- ocx-cursor install
228
111
  tail -n 100 ~/.opencodex/cursor-bridge/service.log
112
+ tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
229
113
  ```
230
114
 
231
- The patcher stops without modifying a file when a Cursor release no longer matches its known code structure. Check `service.error.log` in that case.
232
-
233
- ### OpenCodex models cannot be loaded
234
-
235
- Check OpenCodex first:
236
-
237
- ```bash
238
- ocx status
239
- ocx models --json
240
- curl http://127.0.0.1:10100/v1/models
241
- ```
242
-
243
- 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.
115
+ The patcher stops when a Cursor update no longer matches its known code structure. It reports the file and mismatch in `service.error.log` without replacing that file.
244
116
 
245
117
  ## Development
246
118
 
@@ -250,14 +122,4 @@ npm run check
250
122
  npm pack --dry-run
251
123
  ```
252
124
 
253
- The code uses `node:sqlite`, so development requires Node.js 22.5 or newer.
254
-
255
- ## Compatibility
256
-
257
- The package writes Cursor's local state database, uses Cursor's Safe Storage format, and modifies private code inside `Cursor.app`. Cursor does not document these interfaces. A Cursor update can change them.
258
-
259
- The current release supports macOS. It does not configure Windows Credential Manager, Linux keyrings, or system services outside launchd.
260
-
261
- ## License
262
-
263
- MIT
125
+ The project uses the MIT license.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "OpenCodex companion service for Cursor custom models",
5
5
  "keywords": [
6
6
  "opencodex",
@@ -9,28 +9,44 @@ import {
9
9
  cursorWorkbenchFile,
10
10
  } from "./paths.mjs";
11
11
 
12
- export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v3*/";
12
+ export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v5*/";
13
13
  export const legacyCursorPatchMarker = "/*ocx-cursor-model-metadata*/";
14
14
  export const cursorLocalModeDisabled = "localMode:!1";
15
15
  export const cursorLocalModeEnabled = "localMode:!0";
16
- export const cursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display*/";
16
+ export const cursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display-v2*/";
17
+ export const legacyCursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display*/";
18
+ export const cursorLocalRuntimeCapabilitiesPatchMarker = "/*ocx-cursor-local-model-capabilities-v3*/";
19
+ export const legacyCursorLocalRuntimeCapabilitiesPatchMarker = "/*ocx-cursor-local-model-capabilities-v2*/";
17
20
 
18
21
  export function isCursorModelMetadataBundle(file) {
19
22
  return file === cursorWorkbenchFile || file === cursorGlassWorkbenchFile;
20
23
  }
21
24
 
22
25
  const catalogNormalization = /(?<normalization>\b(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\((?<item>[A-Za-z_$][\w$]*)=>(?<plain>[A-Za-z_$][\w$]*)\(\k<item>\)\)),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
23
- const previousCatalogInjection = /\/\*ocx-cursor-model-metadata(?:-v2)?\*\/(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\(ocxCursorModel=>\{.*?\}\),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
26
+ const previousCatalogInjection = /\/\*ocx-cursor-model-metadata(?:-v[234])?\*\/(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\(ocxCursorModel=>\{.*?\}\),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
24
27
  const localModelConstructor = /function (?<functionName>[A-Za-z_$][\w$]*)\(e,t\)\{return new (?<modelType>[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\(\{modelId:e,displayModelId:e,displayName:null!=t\?t:e,displayNameShort:null!=t\?t:e,aliases:\[\]\}\)\}/g;
28
+ const localRuntimeVisionCapability = /"boolean"==typeof (?<object>[A-Za-z_$][\w$]*)\.supports_vision\?\{supports_vision:\k<object>\.supports_vision\}:\{\}\)/g;
29
+ const localRuntimePickerInput = /toPickerInput\((?<model>[A-Za-z_$][\w$]*)\)\{var [A-Za-z_$][\w$]*;const [A-Za-z_$][\w$]*=this\.modelMetadataById\.get\(\k<model>\.modelId\),(?<capabilities>[A-Za-z_$][\w$]*)=.*?;return\{.*?supportsReasoning:[^,]+,supportsVision:/g;
30
+ const localRuntimeProviderPickerInput = /supportsReasoning:(?<reasoning>!0===\(null===\((?<capability>[A-Za-z_$][\w$]*)=(?<model>[A-Za-z_$][\w$]*)\.capabilities\)\|\|void 0===\k<capability>\?void 0:\k<capability>\.supports_reasoning\)),supportsVision:/g;
31
+ const localRuntimeRequestParameters = /function\((?<payload>[A-Za-z_$][\w$]*),(?<model>[A-Za-z_$][\w$]*),(?<parameters>[A-Za-z_$][\w$]*),(?<apiType>[A-Za-z_$][\w$]*),(?<route>[A-Za-z_$][\w$]*),(?<extended>[A-Za-z_$][\w$]*)=!1\)\{(?=const [A-Za-z_$][\w$]*=function\([A-Za-z_$][\w$]*\)\{var [A-Za-z_$][\w$]*;const [A-Za-z_$][\w$]*=.*?\["reasoning","effort","thought_level"\]\.includes)/g;
32
+ const localRuntimeBuilderExport = /buildBottlerocketPickerModels:\(\)=>(?<builder>[A-Za-z_$][\w$]*)/g;
25
33
  const transientPatchErrorCodes = new Set(["EACCES", "EBUSY", "EPERM"]);
26
34
 
35
+ function replaceSingleMatch(source, pattern, replacement, label) {
36
+ const matches = [...source.matchAll(pattern)];
37
+ if (matches.length !== 1) throw new Error(`Expected one Cursor ${label}, found ${matches.length}`);
38
+ const match = matches[0];
39
+ const value = typeof replacement === "function" ? replacement(match) : replacement;
40
+ return `${source.slice(0, match.index)}${value}${source.slice(match.index + match[0].length)}`;
41
+ }
42
+
27
43
  function metadataInjection(catalog) {
28
- return `${cursorPatchMarker}${catalog}=${catalog}.map(ocxCursorModel=>{if(!ocxCursorModel.name.startsWith("opencodex/"))return ocxCursorModel;const ocxCursorStored=(this._reactiveStorageService.applicationUserPersistentStorage.availableDefaultModels2??[]).find(ocxCursorCandidate=>ocxCursorCandidate?.name===ocxCursorModel.name);const ocxCursorDisplayWords={claude:"Claude",codex:"Codex",composer:"Composer",deepseek:"DeepSeek",fable:"Fable",fast:"Fast",flash:"Flash",gpt:"GPT",grok:"Grok",hy3:"HY3",kimi:"Kimi",luna:"Luna",max:"Max",mimo:"MiMo",mini:"Mini",opus:"Opus",pro:"Pro",qwen:"Qwen",sol:"Sol",sonnet:"Sonnet",spark:"Spark",terra:"Terra"};const ocxCursorDisplayName=ocxCursorModel.name.split("/").at(-1).split("-").map(ocxCursorWord=>{if(ocxCursorDisplayWords[ocxCursorWord])return ocxCursorDisplayWords[ocxCursorWord];const ocxCursorAttached=/^(qwen|kimi|gpt|claude|grok)(\\d+(?:\\.\\d+)*)$/.exec(ocxCursorWord);if(ocxCursorAttached)return ocxCursorDisplayWords[ocxCursorAttached[1]]+" "+ocxCursorAttached[2];if(/^v\\d/i.test(ocxCursorWord))return"V"+ocxCursorWord.slice(1);if(/^k\\d/i.test(ocxCursorWord))return"K"+ocxCursorWord.slice(1);if(/^\\d/.test(ocxCursorWord))return ocxCursorWord;return ocxCursorWord.slice(0,1).toUpperCase()+ocxCursorWord.slice(1)}).join(" ");const ocxCursorPrettyLabel=ocxCursorValue=>typeof ocxCursorValue==="string"?ocxCursorValue.split(ocxCursorModel.name).join(ocxCursorDisplayName):ocxCursorValue;const ocxCursorVariants=(ocxCursorStored?.variants??ocxCursorModel.variants??[]).map(ocxCursorVariant=>({...ocxCursorVariant,displayName:ocxCursorPrettyLabel(ocxCursorVariant.displayName),displayNameOutsidePicker:ocxCursorPrettyLabel(ocxCursorVariant.displayNameOutsidePicker)}));return{...ocxCursorModel,clientDisplayName:ocxCursorDisplayName,inputboxShortModelName:ocxCursorDisplayName,parameterDefinitions:ocxCursorStored?.parameterDefinitions??ocxCursorModel.parameterDefinitions??[],variants:ocxCursorVariants,legacySlugs:ocxCursorStored?.legacySlugs??ocxCursorModel.legacySlugs??[],supportsThinking:ocxCursorStored?.supportsThinking??ocxCursorModel.supportsThinking}}),`;
44
+ return `${cursorPatchMarker}${catalog}=${catalog}.map(ocxCursorModel=>{if(!ocxCursorModel.name.startsWith("opencodex/"))return ocxCursorModel;const ocxCursorStored=(this._reactiveStorageService.applicationUserPersistentStorage.availableDefaultModels2??[]).find(ocxCursorCandidate=>ocxCursorCandidate?.name===ocxCursorModel.name);const ocxCursorDisplayWords={claude:"Claude",codex:"Codex",composer:"Composer",deepseek:"DeepSeek",fable:"Fable",fast:"Fast",flash:"Flash",gpt:"GPT",grok:"Grok",hy3:"HY3",kimi:"Kimi",luna:"Luna",max:"Max",mimo:"MiMo",mini:"Mini",opus:"Opus",pro:"Pro",qwen:"Qwen",sol:"Sol",sonnet:"Sonnet",spark:"Spark",terra:"Terra"};const ocxCursorDisplayName=ocxCursorStored?.clientDisplayName??((ocxCursorModel.name.startsWith("opencodex/cursor/")?"Cursor ":"")+ocxCursorModel.name.split("/").at(-1).split("-").map(ocxCursorWord=>{if(ocxCursorDisplayWords[ocxCursorWord])return ocxCursorDisplayWords[ocxCursorWord];const ocxCursorAttached=/^(qwen|kimi|gpt|claude|grok)(\\d+(?:\\.\\d+)*)$/.exec(ocxCursorWord);if(ocxCursorAttached)return ocxCursorDisplayWords[ocxCursorAttached[1]]+" "+ocxCursorAttached[2];if(/^v\\d/i.test(ocxCursorWord))return"V"+ocxCursorWord.slice(1);if(/^k\\d/i.test(ocxCursorWord))return"K"+ocxCursorWord.slice(1);if(/^\\d/.test(ocxCursorWord))return ocxCursorWord;return ocxCursorWord.slice(0,1).toUpperCase()+ocxCursorWord.slice(1)}).join(" "));const ocxCursorPrettyLabel=ocxCursorValue=>typeof ocxCursorValue==="string"?ocxCursorValue.split(ocxCursorModel.name).join(ocxCursorDisplayName):ocxCursorValue;const ocxCursorDefinitions=Array.isArray(ocxCursorModel.parameterDefinitions)&&ocxCursorModel.parameterDefinitions.length>0?ocxCursorModel.parameterDefinitions:ocxCursorStored?.parameterDefinitions??[];const ocxCursorVariantSource=Array.isArray(ocxCursorModel.variants)&&ocxCursorModel.variants.length>0?ocxCursorModel.variants:ocxCursorStored?.variants??[];const ocxCursorVariants=ocxCursorVariantSource.map(ocxCursorVariant=>({...ocxCursorVariant,displayName:ocxCursorPrettyLabel(ocxCursorVariant.displayName),displayNameOutsidePicker:ocxCursorPrettyLabel(ocxCursorVariant.displayNameOutsidePicker)}));const ocxCursorLegacySlugs=Array.isArray(ocxCursorModel.legacySlugs)&&ocxCursorModel.legacySlugs.length>0?ocxCursorModel.legacySlugs:ocxCursorStored?.legacySlugs??[];return{...ocxCursorModel,clientDisplayName:ocxCursorDisplayName,inputboxShortModelName:ocxCursorDisplayName,parameterDefinitions:ocxCursorDefinitions,variants:ocxCursorVariants,legacySlugs:ocxCursorLegacySlugs,supportsThinking:void 0!==ocxCursorModel.supportsThinking?ocxCursorModel.supportsThinking:ocxCursorStored?.supportsThinking}}),`;
29
45
  }
30
46
 
31
47
  export function patchCursorWorkbenchSource(source) {
32
48
  if (source.includes(cursorPatchMarker)) return { status: "already-patched", source };
33
- if (source.includes(legacyCursorPatchMarker) || source.includes("/*ocx-cursor-model-metadata-v2*/")) {
49
+ if (source.includes(legacyCursorPatchMarker) || source.includes("/*ocx-cursor-model-metadata-v2*/") || source.includes("/*ocx-cursor-model-metadata-v3*/") || source.includes("/*ocx-cursor-model-metadata-v4*/")) {
34
50
  const matches = [...source.matchAll(previousCatalogInjection)];
35
51
  if (matches.length !== 1) {
36
52
  throw new Error(`Expected one legacy Cursor model metadata hook, found ${matches.length}`);
@@ -81,19 +97,103 @@ export function patchCursorBundleSource(source, options = {}) {
81
97
  };
82
98
  }
83
99
 
84
- export function patchCursorLocalRuntimeSource(source) {
85
- if (source.includes(cursorLocalRuntimePatchMarker)) return { status: "already-patched", source };
100
+ function patchCursorLocalRuntimeDisplayName(source) {
101
+ if (source.includes(cursorLocalRuntimePatchMarker)) return source;
102
+ if (source.includes(legacyCursorLocalRuntimePatchMarker)) {
103
+ return source
104
+ .replace(legacyCursorLocalRuntimePatchMarker, cursorLocalRuntimePatchMarker)
105
+ .replace('e.startsWith("opencodex/")?e.split("/")', 'e.startsWith("opencodex/")?(e.startsWith("opencodex/cursor/")?"Cursor ":"")+e.split("/")');
106
+ }
86
107
  const matches = [...source.matchAll(localModelConstructor)];
87
108
  if (matches.length !== 1) {
88
109
  throw new Error(`Expected one Cursor local model constructor, found ${matches.length}`);
89
110
  }
90
111
  const match = matches[0];
91
112
  const { functionName, modelType } = match.groups;
92
- const replacement = `function ${functionName}(e,t){${cursorLocalRuntimePatchMarker}const ocxCursorDisplayWords={claude:"Claude",codex:"Codex",composer:"Composer",deepseek:"DeepSeek",fable:"Fable",fast:"Fast",flash:"Flash",gpt:"GPT",grok:"Grok",hy3:"HY3",kimi:"Kimi",luna:"Luna",max:"Max",mimo:"MiMo",mini:"Mini",opus:"Opus",pro:"Pro",qwen:"Qwen",sol:"Sol",sonnet:"Sonnet",spark:"Spark",terra:"Terra"};const ocxCursorDisplayName=null!=t?t:e.startsWith("opencodex/")?e.split("/").at(-1).split("-").map(ocxCursorWord=>{if(ocxCursorDisplayWords[ocxCursorWord])return ocxCursorDisplayWords[ocxCursorWord];const ocxCursorAttached=/^(qwen|kimi|gpt|claude|grok)(\\d+(?:\\.\\d+)*)$/.exec(ocxCursorWord);if(ocxCursorAttached)return ocxCursorDisplayWords[ocxCursorAttached[1]]+" "+ocxCursorAttached[2];if(/^v\\d/i.test(ocxCursorWord))return"V"+ocxCursorWord.slice(1);if(/^k\\d/i.test(ocxCursorWord))return"K"+ocxCursorWord.slice(1);if(/^\\d/.test(ocxCursorWord))return ocxCursorWord;return ocxCursorWord.slice(0,1).toUpperCase()+ocxCursorWord.slice(1)}).join(" "):e;return new ${modelType}({modelId:e,displayModelId:e,displayName:ocxCursorDisplayName,displayNameShort:ocxCursorDisplayName,aliases:[]})}`;
93
- return {
94
- status: "patched",
95
- source: `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`,
96
- };
113
+ const replacement = `function ${functionName}(e,t){${cursorLocalRuntimePatchMarker}const ocxCursorDisplayWords={claude:"Claude",codex:"Codex",composer:"Composer",deepseek:"DeepSeek",fable:"Fable",fast:"Fast",flash:"Flash",gpt:"GPT",grok:"Grok",hy3:"HY3",kimi:"Kimi",luna:"Luna",max:"Max",mimo:"MiMo",mini:"Mini",opus:"Opus",pro:"Pro",qwen:"Qwen",sol:"Sol",sonnet:"Sonnet",spark:"Spark",terra:"Terra"};const ocxCursorDisplayName=null!=t?t:e.startsWith("opencodex/")?(e.startsWith("opencodex/cursor/")?"Cursor ":"")+e.split("/").at(-1).split("-").map(ocxCursorWord=>{if(ocxCursorDisplayWords[ocxCursorWord])return ocxCursorDisplayWords[ocxCursorWord];const ocxCursorAttached=/^(qwen|kimi|gpt|claude|grok)(\\d+(?:\\.\\d+)*)$/.exec(ocxCursorWord);if(ocxCursorAttached)return ocxCursorDisplayWords[ocxCursorAttached[1]]+" "+ocxCursorAttached[2];if(/^v\\d/i.test(ocxCursorWord))return"V"+ocxCursorWord.slice(1);if(/^k\\d/i.test(ocxCursorWord))return"K"+ocxCursorWord.slice(1);if(/^\\d/.test(ocxCursorWord))return ocxCursorWord;return ocxCursorWord.slice(0,1).toUpperCase()+ocxCursorWord.slice(1)}).join(" "):e;return new ${modelType}({modelId:e,displayModelId:e,displayName:ocxCursorDisplayName,displayNameShort:ocxCursorDisplayName,aliases:[]})}`;
114
+ return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
115
+ }
116
+
117
+ function patchCursorLocalRuntimeCapabilities(source) {
118
+ source = replaceSingleMatch(source, localRuntimeVisionCapability, (match) => {
119
+ const object = match.groups.object;
120
+ return `${match[0].slice(0, -1)},"boolean"==typeof ${object}.supports_fast?{supports_fast:${object}.supports_fast}:{})`;
121
+ }, "local model capability parser");
122
+
123
+ source = replaceSingleMatch(source, localRuntimePickerInput, (match) => {
124
+ const capabilities = match.groups.capabilities;
125
+ return match[0].replace(
126
+ ",supportsVision:",
127
+ `,reasoningEfforts:Array.isArray(${capabilities}?.reasoning_effort)?${capabilities}.reasoning_effort:void 0,supportsFast:!0===${capabilities}?.supports_fast,supportsVision:`,
128
+ );
129
+ }, "local picker input mapper");
130
+
131
+ source = patchCursorLocalRuntimeProviderCapabilities(source);
132
+
133
+ source = replaceSingleMatch(source, localRuntimeRequestParameters, (match) => {
134
+ const { payload, parameters } = match.groups;
135
+ return `${match[0]}const ocxCursorFast=${parameters}?.find(ocxCursorParameter=>ocxCursorParameter.id==="fast")?.value;if(ocxCursorFast==="true")${payload}.service_tier="priority";else if(ocxCursorFast==="false")delete ${payload}.service_tier;`;
136
+ }, "local request parameter mapper");
137
+
138
+ const exportMatches = [...source.matchAll(localRuntimeBuilderExport)];
139
+ if (exportMatches.length !== 1) {
140
+ throw new Error(`Expected one Cursor local picker builder export, found ${exportMatches.length}`);
141
+ }
142
+ const builderName = exportMatches[0].groups.builder;
143
+ const builderStart = source.indexOf(`function ${builderName}(`, exportMatches[0].index);
144
+ if (builderStart === -1) throw new Error("Cursor local picker builder was not found");
145
+ const builderHead = source.slice(builderStart, builderStart + 1_000);
146
+ const modelBuilderName = /\.push\((?<name>[A-Za-z_$][\w$]*)\(/.exec(builderHead)?.groups.name;
147
+ if (!modelBuilderName) throw new Error("Cursor local picker model builder was not found");
148
+
149
+ const modelBuilderStart = source.indexOf(`function ${modelBuilderName}(`, builderStart);
150
+ const modelBuilderSource = source.slice(modelBuilderStart, modelBuilderStart + 6_000);
151
+ const signature = new RegExp(`function ${modelBuilderName}\\((?<model>[A-Za-z_$][\\w$]*),(?<tier>[A-Za-z_$][\\w$]*)\\)\\{var [^;]+;const (?<effort>[A-Za-z_$][\\w$]*)=function\\((?<inner>[A-Za-z_$][\\w$]*)\\)\\{`).exec(modelBuilderSource);
152
+ if (!signature) throw new Error("Cursor local picker model effort builder was not found");
153
+ const { model, effort } = signature.groups;
154
+ const effortPrefix = `const ${effort}=function(`;
155
+ const effortIndex = modelBuilderSource.indexOf(effortPrefix);
156
+ const effortReplacement = `const ${effort}=Array.isArray(${model}.reasoningEfforts)&&${model}.reasoningEfforts.length>0?{param:"reasoning_effort",values:${model}.reasoningEfforts,defaultValue:${model}.reasoningEfforts.includes("medium")?"medium":${model}.reasoningEfforts.includes("high")?"high":${model}.reasoningEfforts[0]}:function(`;
157
+ let patchedBuilder = `${modelBuilderSource.slice(0, effortIndex)}${effortReplacement}${modelBuilderSource.slice(effortIndex + effortPrefix.length)}`;
158
+
159
+ const definitions = new RegExp(`(?<name>[A-Za-z_$][\\w$]*)=\\[\\.\\.\\.void 0!==${effort}\\?`).exec(patchedBuilder)?.groups.name;
160
+ const parameterClass = /new (?<name>[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\(\{id:"reasoning"/.exec(patchedBuilder)?.groups.name;
161
+ if (!definitions || !parameterClass) throw new Error("Cursor local picker parameter definition builder was not found");
162
+ const definitionsEnd = patchedBuilder.indexOf("];var ", patchedBuilder.indexOf(`${definitions}=[`));
163
+ if (definitionsEnd === -1) throw new Error("Cursor local picker parameter definitions were not terminated");
164
+ const fastDefinition = `];${model}.supportsFast&&${definitions}.push(new ${parameterClass}({id:"fast",name:"Fast",markdownTooltip:"Use priority processing with increased usage.",parameterType:{booleanParameter:{values:[{value:"false"},{value:"true",displayName:"Fast",increasesModelCost:!0}]}},isCycleableByHotkey:!1}))`;
165
+ patchedBuilder = `${patchedBuilder.slice(0, definitionsEnd)}${fastDefinition}${patchedBuilder.slice(definitionsEnd + 1)}`;
166
+
167
+ const variantMatch = new RegExp(`variants:(?<call>[A-Za-z_$][\\w$]*\\(${effort},(?<context>[A-Za-z_$][\\w$]*),(?<display>[A-Za-z_$][\\w$]*),(?<tier>[A-Za-z_$][\\w$]*)\\))`).exec(patchedBuilder);
168
+ if (!variantMatch) throw new Error("Cursor local picker variant builder was not found");
169
+ const variantReplacement = `variants:ocxCursorFastVariants(${variantMatch.groups.call},${model}.supportsFast,${variantMatch.groups.display})`;
170
+ patchedBuilder = `${patchedBuilder.slice(0, variantMatch.index)}${variantReplacement}${patchedBuilder.slice(variantMatch.index + variantMatch[0].length)}`;
171
+
172
+ source = `${source.slice(0, modelBuilderStart)}${patchedBuilder}${source.slice(modelBuilderStart + modelBuilderSource.length)}`;
173
+ const helper = `${cursorLocalRuntimeCapabilitiesPatchMarker}function ocxCursorFastVariants(e,t,n){if(!t)return e;const r=e.length>0?e:[{parameterValues:[],displayName:n,displayNameOutsidePicker:n,isMaxMode:!1,isDefaultNonMaxConfig:!0,isDefaultMaxConfig:!0}];return r.flatMap(e=>[{...e,parameterValues:[...(e.parameterValues??[]),{id:"fast",value:"false"}]},{...e,parameterValues:[...(e.parameterValues??[]),{id:"fast",value:"true"}],displayName:e.displayName+" Fast",displayNameOutsidePicker:(e.displayNameOutsidePicker??e.displayName)+" Fast",isDefaultNonMaxConfig:!1,isDefaultMaxConfig:!1}])}`;
174
+ return `${source.slice(0, builderStart)}${helper}${source.slice(builderStart)}`;
175
+ }
176
+
177
+ function patchCursorLocalRuntimeProviderCapabilities(source) {
178
+ return replaceSingleMatch(source, localRuntimeProviderPickerInput, (match) => {
179
+ const { model, reasoning } = match.groups;
180
+ return `supportsReasoning:${reasoning},reasoningEfforts:Array.isArray(${model}.capabilities?.reasoning_effort)?${model}.capabilities.reasoning_effort:void 0,supportsFast:!0===${model}.capabilities?.supports_fast,supportsVision:`;
181
+ }, "standalone local provider picker mapper");
182
+ }
183
+
184
+ export function patchCursorLocalRuntimeSource(source) {
185
+ const displayPatched = patchCursorLocalRuntimeDisplayName(source);
186
+ if (displayPatched.includes(cursorLocalRuntimeCapabilitiesPatchMarker)) {
187
+ return { status: displayPatched === source ? "already-patched" : "patched", source: displayPatched };
188
+ }
189
+ if (displayPatched.includes(legacyCursorLocalRuntimeCapabilitiesPatchMarker)) {
190
+ const upgraded = patchCursorLocalRuntimeProviderCapabilities(displayPatched).replace(
191
+ legacyCursorLocalRuntimeCapabilitiesPatchMarker,
192
+ cursorLocalRuntimeCapabilitiesPatchMarker,
193
+ );
194
+ return { status: "patched", source: upgraded };
195
+ }
196
+ return { status: "patched", source: patchCursorLocalRuntimeCapabilities(displayPatched) };
97
197
  }
98
198
 
99
199
  export async function cursorWorkbenchSignature(file = cursorWorkbenchFile) {
@@ -42,12 +42,14 @@ function displayWord(value) {
42
42
  }
43
43
 
44
44
  export function displayNameFor(model) {
45
- return model.sourceId.split("/").at(-1).split("-").map(displayWord).join(" ");
45
+ const displayName = model.sourceId.split("/").at(-1).split("-").map(displayWord).join(" ");
46
+ return model.provider === "cursor" || model.sourceId.startsWith("cursor/") ? `Cursor ${displayName}` : displayName;
46
47
  }
47
48
 
48
49
  export function cursorIsRunning() {
49
50
  try {
50
- execFileSync("pgrep", ["-f", "/Applications/Cursor.app/Contents/"], { stdio: "ignore" });
51
+ // Match the main binary only: orphaned crashpad helpers under Contents/ outlive the app.
52
+ execFileSync("pgrep", ["-f", "/Applications/Cursor.app/Contents/MacOS/Cursor"], { stdio: "ignore" });
51
53
  return true;
52
54
  } catch {
53
55
  return false;
@@ -165,6 +167,9 @@ function syncSelectedModel(state, catalog) {
165
167
  const composer = state.aiSettings?.modelConfig?.composer;
166
168
  if (!composer || !Array.isArray(composer.selectedModels)) return;
167
169
  const byAlias = new Map(catalog.map((model) => [model.alias, model]));
170
+ composer.selectedModels = composer.selectedModels.filter((selected) => (
171
+ typeof selected?.modelId !== "string" || !selected.modelId.startsWith(managedPrefix) || byAlias.has(selected.modelId)
172
+ ));
168
173
  for (const selected of composer.selectedModels) {
169
174
  const model = byAlias.get(selected.modelId);
170
175
  if (!model || (model.reasoningEfforts.length === 0 && !model.supportsFast)) continue;
@@ -187,7 +192,7 @@ export function applyCatalogToState(state, catalog) {
187
192
  const keepUnmanaged = (value) => typeof value !== "string" || !value.startsWith(managedPrefix);
188
193
  const existingModels = Array.isArray(state.availableDefaultModels2) ? state.availableDefaultModels2 : [];
189
194
  state.availableDefaultModels2 = [
190
- ...existingModels.filter((model) => !(model?.isUserAdded && typeof model.name === "string" && model.name.startsWith(managedPrefix))),
195
+ ...existingModels.filter((model) => !(typeof model?.name === "string" && model.name.startsWith(managedPrefix))),
191
196
  ...catalog.map(cursorModel),
192
197
  ];
193
198
  state.aiSettings ||= {};
@@ -199,6 +204,14 @@ export function applyCatalogToState(state, catalog) {
199
204
  ...(Array.isArray(state.aiSettings.modelOverrideEnabled) ? state.aiSettings.modelOverrideEnabled.filter(keepUnmanaged) : []),
200
205
  ...aliases,
201
206
  ];
207
+ if (state.aiSettings.modelParameterPreferences && typeof state.aiSettings.modelParameterPreferences === "object") {
208
+ const activeAliases = new Set(aliases);
209
+ for (const alias of Object.keys(state.aiSettings.modelParameterPreferences)) {
210
+ if (alias.startsWith(managedPrefix) && !activeAliases.has(alias)) {
211
+ delete state.aiSettings.modelParameterPreferences[alias];
212
+ }
213
+ }
214
+ }
202
215
  syncSelectedModel(state, catalog);
203
216
  return state;
204
217
  }
package/src/gateway.mjs CHANGED
@@ -118,10 +118,15 @@ async function serviceToken() {
118
118
  }
119
119
  }
120
120
 
121
- function authorized(req, expected) {
122
- const supplied = String(req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim();
123
- const actual = Buffer.from(supplied);
124
- return actual.length === expected.length && timingSafeEqual(actual, expected);
121
+ export function authorized(req, expected) {
122
+ const supplied = [
123
+ String(req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim(),
124
+ String(req.headers["x-api-key"] || "").trim(),
125
+ ];
126
+ return supplied.some((value) => {
127
+ const actual = Buffer.from(value);
128
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
129
+ });
125
130
  }
126
131
 
127
132
  function json(res, status, body) {
@@ -161,7 +166,7 @@ function upstreamHeaders(req, body, token, upstream) {
161
166
 
162
167
  export function startGateway(options) {
163
168
  const host = options.host || gatewayHost;
164
- const port = options.port || gatewayPort;
169
+ const port = options.port ?? gatewayPort;
165
170
  const expected = Buffer.from(options.secret);
166
171
  let activeRequests = 0;
167
172
 
@@ -179,16 +184,29 @@ export function startGateway(options) {
179
184
  if (activeRequests >= 8) return json(res, 429, { error: { message: "bridge concurrency limit reached", type: "rate_limit_error" } });
180
185
 
181
186
  activeRequests += 1;
182
- res.once("close", () => { activeRequests = Math.max(0, activeRequests - 1); });
187
+ let upstreamReq;
188
+ let downstreamClosed = false;
189
+ const cancelUpstream = () => {
190
+ downstreamClosed = true;
191
+ if (upstreamReq && !upstreamReq.destroyed) upstreamReq.destroy();
192
+ };
193
+ req.once("aborted", cancelUpstream);
194
+ res.once("close", () => {
195
+ activeRequests = Math.max(0, activeRequests - 1);
196
+ if (!res.writableEnded) cancelUpstream();
197
+ });
183
198
  try {
184
199
  if (route === "GET /v1/models") {
185
200
  return json(res, 200, enrichModelList(await activeModels(), catalog));
186
201
  }
187
202
 
188
- const upstream = opencodexEndpoint();
189
- const body = rewriteModelAliasBody(await readBody(req), catalog);
190
- const token = await serviceToken();
191
- const upstreamReq = http.request({
203
+ const upstream = options.upstream || opencodexEndpoint();
204
+ const receivedBody = await readBody(req);
205
+ if (downstreamClosed) return;
206
+ const body = rewriteModelAliasBody(receivedBody, catalog);
207
+ const token = options.serviceToken !== undefined ? options.serviceToken : await serviceToken();
208
+ if (downstreamClosed) return;
209
+ upstreamReq = http.request({
192
210
  hostname: upstream.host,
193
211
  port: upstream.port,
194
212
  method: req.method,
@@ -204,6 +222,7 @@ export function startGateway(options) {
204
222
  });
205
223
  });
206
224
  upstreamReq.on("error", (error) => {
225
+ if (downstreamClosed) return;
207
226
  if (!res.headersSent) json(res, 502, { error: { message: "OpenCodex upstream unavailable", type: "upstream_error" } });
208
227
  else res.destroy(error);
209
228
  });