ocx-cursor 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,6 +185,12 @@ The status output should show `Service: running` and `Gateway: healthy`. The pub
185
185
 
186
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
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
+
188
194
  ## How requests are routed
189
195
 
190
196
  ```text
@@ -204,12 +210,15 @@ The gateway API key is generated during `init` and stored in Cursor with macOS S
204
210
  | --- | --- |
205
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. |
206
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. |
207
214
  | `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
208
215
  | `ocx-cursor status` | Show service health, model count, and pending sync state. |
209
216
  | `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
210
217
 
211
218
  `uninstall` leaves Cursor's custom endpoint and model records in its state database.
212
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
+
213
222
  ## Model mapping
214
223
 
215
224
  The bridge maps source model IDs to Cursor aliases:
@@ -5,7 +5,7 @@ import process from "node:process";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
7
7
  import { cursorIsRunning } from "../src/cursor-state.mjs";
8
- import { installService, prepareInstallSecret, serviceStatus, uninstallService } from "../src/install.mjs";
8
+ import { installService, prepareInstallSecret, serviceStatus, uninstallService, updateService } from "../src/install.mjs";
9
9
  import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
10
10
  import { runService } from "../src/service.mjs";
11
11
  import { normalizeBaseUrl, testTunnel } from "../src/setup.mjs";
@@ -18,6 +18,7 @@ Usage:
18
18
  Install the service, test the tunnel, configure Cursor,
19
19
  and sync models
20
20
  ocx-cursor install Install and start the macOS companion service
21
+ ocx-cursor update Install the latest companion release and restart it
21
22
  ocx-cursor sync Sync active OpenCodex models into Cursor
22
23
  ocx-cursor status Show service and model-sync status
23
24
  ocx-cursor uninstall Stop and remove the companion service
@@ -109,6 +110,11 @@ async function main() {
109
110
  printSync(await syncNow());
110
111
  return;
111
112
  }
113
+ if (command === "update") {
114
+ process.stdout.write("Updating OpenCodex Cursor Bridge from npm...\n");
115
+ await updateService();
116
+ return;
117
+ }
112
118
  if (command === "sync") {
113
119
  printSync(await syncNow());
114
120
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { access, chmod, copyFile, cp, lstat, mkdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { access, chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import {
@@ -176,6 +177,24 @@ export async function installService() {
176
177
  return { installRoot, launchAgentFile, cliLinkFile, secretStatus };
177
178
  }
178
179
 
180
+ export async function updateService(options = {}) {
181
+ const execute = options.execFileSync || execFileSync;
182
+ const ownsTemporaryDirectory = !options.temporaryDirectory;
183
+ const temporaryDirectory = options.temporaryDirectory || await mkdtemp(join(tmpdir(), "ocx-cursor-update-"));
184
+ try {
185
+ execute(options.npmCommand || "npm", [
186
+ "exec",
187
+ "--yes",
188
+ "--package=ocx-cursor@latest",
189
+ "--",
190
+ "ocx-cursor",
191
+ "install",
192
+ ], { cwd: temporaryDirectory, stdio: "inherit" });
193
+ } finally {
194
+ if (ownsTemporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true });
195
+ }
196
+ }
197
+
179
198
  export async function uninstallService() {
180
199
  bootout(serviceLabel);
181
200
  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");