cursor-route 0.1.10 → 0.1.11
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/CHANGELOG.md +6 -0
- package/README.md +12 -9
- package/SECURITY.md +1 -1
- package/dist/adapters/opencode.js +8 -13
- package/dist/cli.js +3 -3
- package/dist/config.js +2 -28
- package/dist/zen-free.js +237 -0
- package/docs/briefs/WORKING.md +8 -5
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/skills/route-orch/SKILL.md +1 -1
- package/src/adapters/opencode.test.ts +84 -25
- package/src/adapters/opencode.ts +8 -12
- package/src/cli.test.ts +3 -3
- package/src/cli.ts +3 -3
- package/src/config.ts +7 -31
- package/src/zen-free.test.ts +137 -0
- package/src/zen-free.ts +258 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
_(none)_
|
|
6
6
|
|
|
7
|
+
## 0.1.11 — 2026-08-21
|
|
8
|
+
|
|
9
|
+
- `--worker opencode --model free` now **ranks the live OpenCode Zen catalog** (`GET https://opencode.ai/zen/v1/models`, cached ~15 min) instead of hardcoding `opencode/big-pickle`. Ox Alpha (`opencode/x-preview-f-free`) wins while it is listed and free; coding `-free` models next; `big-pickle` last among non-contributor free. Fetch-fail fallback is Ox Alpha.
|
|
10
|
+
- Pin with `--model provider/model` or `CURSOR_ROUTE_OPENCODE_MODEL`. Tests/CI: `CURSOR_ROUTE_ZEN_OFFLINE=1` or `CURSOR_ROUTE_ZEN_CATALOG_JSON` (health never fetches — cache or fallback only).
|
|
11
|
+
- Mid stays `claude-ds`; easy stays OpenRouter chat. OpenCode is still opt-in, not a lane default.
|
|
12
|
+
|
|
7
13
|
## 0.1.10 — 2026-08-21
|
|
8
14
|
|
|
9
15
|
- Opt-in `--worker opencode`: OpenCode `run` as a coding agent that defaults to Zen free model `opencode/big-pickle` (alias `--model free`). Not a lane default — mid stays `claude-ds`, easy stays OpenRouter chat.
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ If you already live in Cursor, X Premium (Grok CLI), and DeepSeek — stop payin
|
|
|
29
29
|
| Grok CLI | X Premium | `--lane hard` implement |
|
|
30
30
|
| DeepSeek via claude-ds | DeepSeek API / plan | `--lane mid` implement (**Flash** default; `--model pro` when needed) |
|
|
31
31
|
| OpenRouter free models | OpenRouter API (free tier) | `--lane easy` wording/drafts — non-secret prompts only (see Security) |
|
|
32
|
-
| OpenCode (opt-in) | OpenCode Zen free models | `--worker opencode` implement on
|
|
32
|
+
| OpenCode (opt-in) | OpenCode Zen free models | `--worker opencode` implement on live Zen free pick (Ox Alpha while listed) |
|
|
33
33
|
| Codex / extra Claude | Optional | Not required for v0 |
|
|
34
34
|
|
|
35
35
|
Exact dollars vary — the point is **reuse subscriptions you already have**.
|
|
@@ -227,18 +227,20 @@ key-shaped material in `start` / `send`, and the runner re-checks the prompt fil
|
|
|
227
227
|
## OpenCode setup (opt-in free coding worker)
|
|
228
228
|
|
|
229
229
|
`--worker opencode` runs [OpenCode](https://opencode.ai) as a **coding agent** on
|
|
230
|
-
OpenCode Zen free models (
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
live OpenCode Zen free models (`--model free` fetches the catalog and ranks,
|
|
231
|
+
same idea as the OpenRouter free picker). Ox Alpha (`opencode/x-preview-f-free`)
|
|
232
|
+
wins while it is listed and free. This is **not** a lane default — mid stays
|
|
233
|
+
`claude-ds`; easy stays OpenRouter chat (no tools). Use it to burn fewer Grok /
|
|
234
|
+
DeepSeek tokens on implement work.
|
|
233
235
|
|
|
234
236
|
```bash
|
|
235
237
|
npm i -g opencode-ai # or: brew install opencode
|
|
236
238
|
opencode auth login # connect OpenCode Zen (or another provider)
|
|
237
|
-
# optional:
|
|
238
|
-
export CURSOR_ROUTE_OPENCODE_MODEL=opencode/
|
|
239
|
+
# optional pin (skips the live catalog pick):
|
|
240
|
+
export CURSOR_ROUTE_OPENCODE_MODEL=opencode/hy3-free
|
|
239
241
|
|
|
240
242
|
cursor-route health # worker:opencode should be ✓
|
|
241
|
-
cursor-route start --worker opencode "…" #
|
|
243
|
+
cursor-route start --worker opencode "…" # live Zen free pick
|
|
242
244
|
cursor-route start --worker opencode --model free "…"
|
|
243
245
|
cursor-route start --worker opencode --model opencode/hy3-free "…"
|
|
244
246
|
```
|
|
@@ -247,7 +249,8 @@ The adapter launches `opencode run --dir <cwd> --model <id>` with the prompt via
|
|
|
247
249
|
`cat` (never interpolated). Always-approve maps to `--auto` (explicit `"deny"`
|
|
248
250
|
rules still apply); `--ask` omits `--auto`. It never rewrites
|
|
249
251
|
`~/.config/opencode/opencode.json`, so parallel jobs don't race. Override the
|
|
250
|
-
binary with `CURSOR_ROUTE_OPENCODE_BIN`.
|
|
252
|
+
binary with `CURSOR_ROUTE_OPENCODE_BIN`. `--model free` caches the ranked catalog
|
|
253
|
+
for ~15 minutes (`CURSOR_ROUTE_ZEN_CACHE_MINUTES`).
|
|
251
254
|
|
|
252
255
|
**Non-secret prompts:** several Zen free models may log or train on prompts during
|
|
253
256
|
their free period (see [OpenCode Zen](https://opencode.ai/docs/zen/) privacy notes).
|
|
@@ -280,7 +283,7 @@ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANT
|
|
|
280
283
|
Run `npm i -g cursor-route`, install tmux if needed, then run `cursor-route health`. The package is available at https://www.npmjs.com/package/cursor-route, and the source is at https://github.com/cemini23/cursor-route.
|
|
281
284
|
|
|
282
285
|
**Is it free?**
|
|
283
|
-
The cursor-route code is open source under MIT. It does not make the worker services free. Your costs depend on Cursor, DeepSeek API usage, and the Grok access or balance available to you. The easy lane can be free on OpenRouter's free-model route (`openrouter/free`). `--worker opencode` can run OpenCode Zen free models (
|
|
286
|
+
The cursor-route code is open source under MIT. It does not make the worker services free. Your costs depend on Cursor, DeepSeek API usage, and the Grok access or balance available to you. The easy lane can be free on OpenRouter's free-model route (`openrouter/free`). `--worker opencode` can run live OpenCode Zen free models (`--model free` ranks the catalog).
|
|
284
287
|
|
|
285
288
|
## Related
|
|
286
289
|
|
package/SECURITY.md
CHANGED
|
@@ -15,7 +15,7 @@ Do **not** open a public issue for credential leaks or RCE-class bugs.
|
|
|
15
15
|
|
|
16
16
|
1. **No secrets in prompts or job files.** Do not paste API keys, SSH private keys, Discord tokens, or `.env` contents into `cursor-route start` / `send` prompts.
|
|
17
17
|
2. **Free OpenRouter easy-lane models may log prompts.** `--lane easy` / `--worker openrouter` is for **non-secret** wording/drafts only. The refuse gate (`start` / `send`, plus the runner's re-check) applies to every lane — never send key-shaped material to the easy lane.
|
|
18
|
-
3. **OpenCode Zen free models may log or train during their free period.** `--worker opencode`
|
|
18
|
+
3. **OpenCode Zen free models may log or train during their free period.** `--worker opencode --model free` picks from the live Zen catalog (Ox Alpha / `x-preview-f-free` currently ranks first and is zero-retention). Treat other free Zen models like the easy lane for secrets. Pin `CURSOR_ROUTE_OPENCODE_MODEL` when you need a specific id.
|
|
19
19
|
4. **Always-approve is powerful.** Default headless workers skip interactive permission prompts (`--always-approve` / `--dangerously-skip-permissions` / OpenCode `--auto`). Opt out with `--ask` / `CURSOR_ROUTE_ASK=1` when you need HITL.
|
|
20
20
|
5. **Install from known channels only.** Prefer `npm i -g cursor-route` or a git clone of this repo. Do not pipe unknown `curl | bash` installers as the primary path.
|
|
21
21
|
6. **Workers inherit your auth.** Grok CLI, Claude Code / claude-ds, and OpenCode use *your* local login. Treat worker panes like you would treat your own shell.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { shellQuote } from "../util.js";
|
|
4
|
-
import { openCodeModel, OPENCODE_DEFAULT_MODEL } from "../config.js";
|
|
4
|
+
import { openCodeModel, OPENCODE_DEFAULT_MODEL, cachedZenFreePick } from "../config.js";
|
|
5
5
|
/**
|
|
6
|
-
* Opt-in OpenCode worker (`opencode run`) — a coding agent
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Opt-in OpenCode worker (`opencode run`) — a coding agent on live OpenCode
|
|
7
|
+
* Zen free models (`--model free` ranks the catalog; Ox Alpha wins while
|
|
8
|
+
* listed). Not a lane default: mid stays claude-ds; easy stays OpenRouter
|
|
9
|
+
* chat (no tools).
|
|
10
10
|
*
|
|
11
11
|
* We never rewrite ~/.config/opencode/opencode.json (parallel jobs would
|
|
12
12
|
* race). Always-approve maps to `opencode run --auto` (still honors explicit
|
|
@@ -42,18 +42,13 @@ export const opencodeAdapter = {
|
|
|
42
42
|
detail: "opencode not found — install: npm i -g opencode-ai (or brew install opencode). Then: opencode auth login. Mid default remains claude-ds.",
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
defaultModel = openCodeModel();
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
/* invalid env — health still ok; startJob will fail loud */
|
|
51
|
-
}
|
|
45
|
+
// Health stays offline: show a fresh cache hit, else the Ox Alpha fallback.
|
|
46
|
+
const pick = cachedZenFreePick() || OPENCODE_DEFAULT_MODEL;
|
|
52
47
|
return {
|
|
53
48
|
worker: "opencode",
|
|
54
49
|
ok: true,
|
|
55
50
|
binary,
|
|
56
|
-
detail: `ok (opencode run;
|
|
51
|
+
detail: `ok (opencode run; --model free = live Zen pick, now ${pick}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
|
|
57
52
|
};
|
|
58
53
|
},
|
|
59
54
|
buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
|
package/dist/cli.js
CHANGED
|
@@ -31,7 +31,7 @@ Usage:
|
|
|
31
31
|
Start options:
|
|
32
32
|
--worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
|
|
33
33
|
--lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
|
|
34
|
-
--model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (
|
|
34
|
+
--model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (live Zen catalog pick) or provider/model
|
|
35
35
|
--dir <path> Working directory (default: cwd)
|
|
36
36
|
--ask Disable always-approve for this job
|
|
37
37
|
--dry-run Print launch command; do not start
|
|
@@ -53,7 +53,7 @@ Env:
|
|
|
53
53
|
OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
|
|
54
54
|
CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
|
|
55
55
|
OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
|
|
56
|
-
CURSOR_ROUTE_OPENCODE_MODEL OpenCode model (
|
|
56
|
+
CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
|
|
57
57
|
`);
|
|
58
58
|
process.exit(exitCode);
|
|
59
59
|
}
|
|
@@ -156,7 +156,7 @@ function asOpenCodeModel(v) {
|
|
|
156
156
|
if (v === undefined || v === true)
|
|
157
157
|
return undefined;
|
|
158
158
|
if (typeof v !== "string") {
|
|
159
|
-
throw new Error(`Invalid --model; expected provider/model (e.g. opencode/
|
|
159
|
+
throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
|
|
160
160
|
}
|
|
161
161
|
return openCodeModel(v);
|
|
162
162
|
}
|
package/dist/config.js
CHANGED
|
@@ -49,33 +49,7 @@ export function openRouterModel() {
|
|
|
49
49
|
export function openRouterBaseUrl() {
|
|
50
50
|
return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
* Default OpenCode model: OpenCode Zen Big Pickle (free, limited-time).
|
|
54
|
-
* Override: CURSOR_ROUTE_OPENCODE_MODEL or `--model provider/model`.
|
|
55
|
-
* Alias `free` resolves to this default (or the env override).
|
|
56
|
-
*/
|
|
57
|
-
export const OPENCODE_DEFAULT_MODEL = "opencode/big-pickle";
|
|
58
|
-
/** Whitelist OpenCode `provider/model` ids (no spaces / injection). */
|
|
59
|
-
export function assertOpenCodeModel(id) {
|
|
60
|
-
if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
|
|
61
|
-
throw new Error(`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/big-pickle) or free`);
|
|
62
|
-
}
|
|
63
|
-
return id;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Resolve OpenCode `--model` / env to a concrete `provider/model` id.
|
|
67
|
-
* Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL, else OPENCODE_DEFAULT_MODEL.
|
|
68
|
-
*/
|
|
69
|
-
export function openCodeModel(raw) {
|
|
70
|
-
const v = (raw ?? "").trim();
|
|
71
|
-
if (!v || v.toLowerCase() === "free") {
|
|
72
|
-
const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
|
|
73
|
-
if (!env || env.toLowerCase() === "free")
|
|
74
|
-
return OPENCODE_DEFAULT_MODEL;
|
|
75
|
-
return assertOpenCodeModel(env);
|
|
76
|
-
}
|
|
77
|
-
return assertOpenCodeModel(v);
|
|
78
|
-
}
|
|
52
|
+
export { OPENCODE_DEFAULT_MODEL, assertOpenCodeModel, openCodeModel, cachedZenFreePick, } from "./zen-free.js";
|
|
79
53
|
function maxConcurrentJobsFromEnv() {
|
|
80
54
|
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
81
55
|
if (raw) {
|
|
@@ -92,7 +66,7 @@ function maxConcurrentJobsFromEnv() {
|
|
|
92
66
|
*/
|
|
93
67
|
export const config = {
|
|
94
68
|
product: "cursor-route",
|
|
95
|
-
version: "0.1.
|
|
69
|
+
version: "0.1.11",
|
|
96
70
|
get jobsDir() {
|
|
97
71
|
return defaultJobsDir();
|
|
98
72
|
},
|
package/dist/zen-free.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Live OpenCode Zen free-model pick — same idea as agent-toolkit
|
|
7
|
+
* `select-openrouter-free-model.ps1`: probe the catalog, keep $0 text/coding
|
|
8
|
+
* models, rank, cache ~15 min. Hardcoded ids are fallback only.
|
|
9
|
+
*
|
|
10
|
+
* Catalog: GET https://opencode.ai/zen/v1/models (no key). Zen ids are
|
|
11
|
+
* `big-pickle` / `x-preview-f-free`; the CLI wants `opencode/<id>`.
|
|
12
|
+
*/
|
|
13
|
+
export const ZEN_MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
|
|
14
|
+
/** Offline / fetch-fail fallback — Ox Alpha (zero-retention, currently top-tier free). */
|
|
15
|
+
export const OPENCODE_DEFAULT_MODEL = "opencode/x-preview-f-free";
|
|
16
|
+
const EXCLUDE_RE = /lyria|whisper|tts|embed|embedding|image|vision-only|audio|diffusion|flux|stable-diffusion|moderation/i;
|
|
17
|
+
const CODING_RE = /coder|instruct|chat|laguna|nemotron|glm|qwen|kimi|deepseek|mimo|hy3|gpt-oss|north|gemma/i;
|
|
18
|
+
const OX_ALPHA_RE = /x-preview|ox-alpha|oxalpha/i;
|
|
19
|
+
const CONTRIBUTOR_RE = /contributor-free/i;
|
|
20
|
+
function zenModelsUrl() {
|
|
21
|
+
return (process.env.CURSOR_ROUTE_ZEN_MODELS_URL || ZEN_MODELS_URL_DEFAULT).trim();
|
|
22
|
+
}
|
|
23
|
+
function cacheMinutes() {
|
|
24
|
+
const n = Number(process.env.CURSOR_ROUTE_ZEN_CACHE_MINUTES);
|
|
25
|
+
return Number.isFinite(n) && n > 0 ? n : 15;
|
|
26
|
+
}
|
|
27
|
+
function cachePath() {
|
|
28
|
+
return process.env.CURSOR_ROUTE_ZEN_CACHE_PATH?.trim() ||
|
|
29
|
+
join(tmpdir(), "cursor-route-zen-best-free.json");
|
|
30
|
+
}
|
|
31
|
+
export function assertOpenCodeModel(id) {
|
|
32
|
+
if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
|
|
33
|
+
throw new Error(`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
|
|
34
|
+
}
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
/** Prefix a Zen catalog id (`x-preview-f-free`) for `opencode run --model`. */
|
|
38
|
+
export function asOpenCodeZenId(raw) {
|
|
39
|
+
const id = raw.trim();
|
|
40
|
+
if (!id)
|
|
41
|
+
throw new Error("empty Zen model id");
|
|
42
|
+
return id.includes("/") ? assertOpenCodeModel(id) : assertOpenCodeModel(`opencode/${id}`);
|
|
43
|
+
}
|
|
44
|
+
function num(v) {
|
|
45
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
46
|
+
return v;
|
|
47
|
+
if (typeof v === "string" && v.trim()) {
|
|
48
|
+
const n = Number(v);
|
|
49
|
+
if (Number.isFinite(n))
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
return NaN;
|
|
53
|
+
}
|
|
54
|
+
export function isZenFreeModel(m) {
|
|
55
|
+
const id = (m.id || "").trim();
|
|
56
|
+
if (!id)
|
|
57
|
+
return false;
|
|
58
|
+
if (EXCLUDE_RE.test(id) || EXCLUDE_RE.test(m.name || ""))
|
|
59
|
+
return false;
|
|
60
|
+
const prompt = num(m.pricing?.prompt);
|
|
61
|
+
const completion = num(m.pricing?.completion);
|
|
62
|
+
const pricedFree = prompt === 0 && completion === 0;
|
|
63
|
+
const tagged = id.endsWith("-free") ||
|
|
64
|
+
id.endsWith(":free") ||
|
|
65
|
+
/(^|\/)big-pickle$/i.test(id);
|
|
66
|
+
return tagged || pricedFree;
|
|
67
|
+
}
|
|
68
|
+
/** Higher boost wins. Ox Alpha first while it is in the free catalog. */
|
|
69
|
+
export function zenFreeBoost(id) {
|
|
70
|
+
const s = id.toLowerCase();
|
|
71
|
+
if (OX_ALPHA_RE.test(s))
|
|
72
|
+
return 40;
|
|
73
|
+
if (CONTRIBUTOR_RE.test(s))
|
|
74
|
+
return 1;
|
|
75
|
+
if (CODING_RE.test(s))
|
|
76
|
+
return 20;
|
|
77
|
+
if (s.endsWith("-free") || s.endsWith(":free"))
|
|
78
|
+
return 10;
|
|
79
|
+
if (/(^|\/)big-pickle$/.test(s))
|
|
80
|
+
return 5;
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
export function rankZenFreeModels(models) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const m of models) {
|
|
86
|
+
if (!isZenFreeModel(m))
|
|
87
|
+
continue;
|
|
88
|
+
const raw = (m.id || "").trim();
|
|
89
|
+
let id;
|
|
90
|
+
try {
|
|
91
|
+
id = asOpenCodeZenId(raw);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const ctx = Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : 0;
|
|
97
|
+
out.push({
|
|
98
|
+
id,
|
|
99
|
+
name: (m.name || raw).trim(),
|
|
100
|
+
context_length: ctx,
|
|
101
|
+
boost: zenFreeBoost(id),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
out.sort((a, b) => {
|
|
105
|
+
if (b.boost !== a.boost)
|
|
106
|
+
return b.boost - a.boost;
|
|
107
|
+
if (b.context_length !== a.context_length)
|
|
108
|
+
return b.context_length - a.context_length;
|
|
109
|
+
return a.id.localeCompare(b.id);
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function readCache() {
|
|
114
|
+
const p = cachePath();
|
|
115
|
+
if (!existsSync(p))
|
|
116
|
+
return null;
|
|
117
|
+
try {
|
|
118
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
119
|
+
if (!j?.id)
|
|
120
|
+
return null;
|
|
121
|
+
const ageMs = Date.now() - Date.parse(j.picked_at);
|
|
122
|
+
if (!Number.isFinite(ageMs) || ageMs < 0)
|
|
123
|
+
return null;
|
|
124
|
+
if (ageMs > cacheMinutes() * 60_000)
|
|
125
|
+
return null;
|
|
126
|
+
assertOpenCodeModel(j.id);
|
|
127
|
+
return j;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function writeCache(pick) {
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(cachePath(), JSON.stringify(pick), { mode: 0o600 });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* cache is optional */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function httpGetSync(url, timeoutMs = 4000) {
|
|
142
|
+
const sec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
143
|
+
const curl = spawnSync("curl", ["-fsS", "--max-time", String(sec), "-H", "Accept: application/json", url], { encoding: "utf8", timeout: timeoutMs + 500, env: { ...process.env } });
|
|
144
|
+
if (curl.status === 0 && curl.stdout?.trim())
|
|
145
|
+
return curl.stdout;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function parseCatalog(raw) {
|
|
149
|
+
const j = JSON.parse(raw);
|
|
150
|
+
if (Array.isArray(j))
|
|
151
|
+
return j;
|
|
152
|
+
if (Array.isArray(j.data))
|
|
153
|
+
return j.data;
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
export function loadZenCatalog() {
|
|
157
|
+
const inline = process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON?.trim();
|
|
158
|
+
if (inline)
|
|
159
|
+
return parseCatalog(inline);
|
|
160
|
+
if (process.env.CURSOR_ROUTE_ZEN_OFFLINE === "1")
|
|
161
|
+
return [];
|
|
162
|
+
const body = httpGetSync(zenModelsUrl());
|
|
163
|
+
if (!body)
|
|
164
|
+
return [];
|
|
165
|
+
try {
|
|
166
|
+
return parseCatalog(body);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Last live pick if the cache is still fresh — health-safe (no network). */
|
|
173
|
+
export function cachedZenFreePick() {
|
|
174
|
+
try {
|
|
175
|
+
return readCache()?.id ?? null;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Best live Zen free model, or OPENCODE_DEFAULT_MODEL if the catalog is empty/offline.
|
|
183
|
+
* `CURSOR_ROUTE_ZEN_REFRESH=1` skips cache. Tests: `CURSOR_ROUTE_ZEN_OFFLINE=1` or
|
|
184
|
+
* `CURSOR_ROUTE_ZEN_CATALOG_JSON`.
|
|
185
|
+
*/
|
|
186
|
+
export function pickZenFreeModel(opts) {
|
|
187
|
+
const refresh = opts?.refresh ||
|
|
188
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH === "1";
|
|
189
|
+
if (!refresh && opts?.catalog === undefined) {
|
|
190
|
+
const cached = readCache();
|
|
191
|
+
if (cached)
|
|
192
|
+
return cached.id;
|
|
193
|
+
}
|
|
194
|
+
const models = opts?.catalog ?? loadZenCatalog();
|
|
195
|
+
const ranked = rankZenFreeModels(models);
|
|
196
|
+
const winner = ranked[0];
|
|
197
|
+
if (!winner) {
|
|
198
|
+
const fallback = {
|
|
199
|
+
id: OPENCODE_DEFAULT_MODEL,
|
|
200
|
+
name: "Ox Alpha Free (fallback)",
|
|
201
|
+
context_length: 0,
|
|
202
|
+
boost: 0,
|
|
203
|
+
picked_at: new Date().toISOString(),
|
|
204
|
+
candidates: 0,
|
|
205
|
+
source: "fallback",
|
|
206
|
+
};
|
|
207
|
+
if (opts?.catalog === undefined)
|
|
208
|
+
writeCache(fallback);
|
|
209
|
+
return fallback.id;
|
|
210
|
+
}
|
|
211
|
+
const pick = {
|
|
212
|
+
id: winner.id,
|
|
213
|
+
name: winner.name,
|
|
214
|
+
context_length: winner.context_length,
|
|
215
|
+
boost: winner.boost,
|
|
216
|
+
picked_at: new Date().toISOString(),
|
|
217
|
+
candidates: ranked.length,
|
|
218
|
+
source: "ranked-free",
|
|
219
|
+
};
|
|
220
|
+
if (opts?.catalog === undefined)
|
|
221
|
+
writeCache(pick);
|
|
222
|
+
return pick.id;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Resolve OpenCode `--model` / env to a concrete `provider/model` id.
|
|
226
|
+
* Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL pin, else live Zen free pick.
|
|
227
|
+
*/
|
|
228
|
+
export function openCodeModel(raw) {
|
|
229
|
+
const v = (raw ?? "").trim();
|
|
230
|
+
if (!v || v.toLowerCase() === "free") {
|
|
231
|
+
const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
|
|
232
|
+
if (env && env.toLowerCase() !== "free")
|
|
233
|
+
return assertOpenCodeModel(env);
|
|
234
|
+
return pickZenFreeModel();
|
|
235
|
+
}
|
|
236
|
+
return assertOpenCodeModel(v);
|
|
237
|
+
}
|
package/docs/briefs/WORKING.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: cursor-route workspace — working brief (edit in place)
|
|
3
3
|
repo: ~/Projects/cursor-route
|
|
4
|
-
npm: cursor-route@0.1.
|
|
4
|
+
npm: cursor-route@0.1.11 (LIVE on npm latest)
|
|
5
5
|
created: 2026-08-12
|
|
6
6
|
updated: 2026-08-21
|
|
7
7
|
---
|
|
@@ -21,11 +21,11 @@ Cursor Agent plans. Workers run in tmux via `cursor-route`:
|
|
|
21
21
|
| `easy` | OpenRouter free | Wording / drafts — non-secret prompts only |
|
|
22
22
|
| `mid` | claude-ds (DeepSeek behind Claude Code) | Default implement (**Flash**; `--model pro` when needed) |
|
|
23
23
|
| `hard` | Grok CLI | Hard implement |
|
|
24
|
-
| opt-in | OpenCode | `--worker opencode` coding agent
|
|
24
|
+
| opt-in | OpenCode | `--worker opencode` coding agent; `--model free` ranks live Zen catalog (Ox Alpha first while listed) |
|
|
25
25
|
|
|
26
26
|
Always-approve on for coding worktrees (`--ask` / `CURSOR_ROUTE_ASK=1` to opt out) — not LIVE Discord/trading. Jobs live in `~/.local/share/cursor-route/jobs`, not in this clone.
|
|
27
27
|
|
|
28
|
-
Install: `npm i -g cursor-route` → **0.1.
|
|
28
|
+
Install: `npm i -g cursor-route` → **0.1.11**. Release notes: [CHANGELOG.md](../../CHANGELOG.md).
|
|
29
29
|
|
|
30
30
|
## Open (edit / check off)
|
|
31
31
|
|
|
@@ -39,7 +39,8 @@ Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (Open
|
|
|
39
39
|
- [x] **Experimental `--worker deepseek`** — 0.1.8: real dsh adapter (`dsh --profile headless` + per-job Cordis patch pins the model; never writes `~/.dsh/settings.yaml`). Always-approve → `DSH_PERMISSION_MODE=danger-full-access`, `--ask` → `workspace-write`; key via env only. Health ✓ needs `dsh` + `DEEPSEEK_API_KEY` (override `CURSOR_ROUTE_DSH_BIN`). Mid stays **claude-ds**.
|
|
40
40
|
- [x] **route-orch brief steals (2026-08-14)** — AutoDesign / misevolution / Vero habits into the public skill: **Verify / claim closeout** (external eval contract; activity ≠ verification), **Eval & skill hygiene** (mid-run Verify-rewrite ban; skill misevolution HITL — no auto-promotion of worker-trajectory variants; verify-fail → reconsider plan/definition + stage attribution spawn/execute/verify); handoff shape Success criteria + Verify + NEVER; skills synced; mid stays claude-ds.
|
|
41
41
|
- [x] **Health proves mid DeepSeek + evidence tree** — 0.1.9: `lane:mid` ✓ only when DeepSeek is proven (shim or DeepSeek `ANTHROPIC_BASE_URL`; Anthropic hatch is not proof); health JSON `lanes.mid`; `status --json` evidence tree (`spawn` / `execute` / `verify.claim=unverified`); skill health-before-mid + evidence-tree closeout. Mid stays **claude-ds**.
|
|
42
|
-
- [x] **Opt-in `--worker opencode`** — 0.1.10: `opencode run --dir` + `--model
|
|
42
|
+
- [x] **Opt-in `--worker opencode`** — 0.1.10: `opencode run --dir` + `--model`; always-approve → `--auto`; `--ask` omits it; never rewrites `~/.config/opencode/opencode.json`. Health ✓ needs `opencode` on PATH (`CURSOR_ROUTE_OPENCODE_BIN`). Mid stays **claude-ds**; easy stays OpenRouter chat. Free Zen may log/train — non-secret prompts.
|
|
43
|
+
- [x] **Live Zen free pick** — 0.1.11: `--model free` ranks `GET https://opencode.ai/zen/v1/models` (Ox Alpha first while listed; ~15 min cache; Ox Alpha fallback if fetch fails). Pin with `CURSOR_ROUTE_OPENCODE_MODEL`. Health stays offline (cache/fallback only).
|
|
43
44
|
- [ ] **Hero GIF** — still outstanding; dry-run fixture ships as the substitute for now (`docs/fixtures/hero-demo.log` — see `docs/DEMO_GIF.md`)
|
|
44
45
|
- [x] **Do not** paste private `ROUTE_KIT`, SIP, prod paths, or hang-watchdog env into this public repo
|
|
45
46
|
|
|
@@ -53,6 +54,7 @@ Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (Open
|
|
|
53
54
|
| `src/adapters/grok.ts` | Hard worker |
|
|
54
55
|
| `src/adapters/openrouter.ts` | Easy worker |
|
|
55
56
|
| `src/adapters/opencode.ts` | Opt-in OpenCode worker (`--worker opencode`; mid stays claude-ds) |
|
|
57
|
+
| `src/zen-free.ts` | Live Zen catalog rank for `--model free` |
|
|
56
58
|
| `skills/route-orch/SKILL.md` | Cursor skill — spawn CLI, do not implement in-session |
|
|
57
59
|
| `CHANGELOG.md` | Release notes |
|
|
58
60
|
| `SECURITY.md` | Secret refuse gate |
|
|
@@ -71,4 +73,5 @@ Install: `npm i -g cursor-route` → **0.1.9** live; **0.1.10** unreleased (Open
|
|
|
71
73
|
| 2026-08-14 | Experimental `--worker deepseek` wired to official dsh (headless + per-job patch + `DSH_PERMISSION_MODE` + key-via-env); `--model` applies to claude-ds + deepseek; mid stays claude-ds → 0.1.8 LIVE. |
|
|
72
74
|
| 2026-08-14 | route-orch brief steals (AutoDesign / misevolution / Vero): **Verify / claim closeout** + **Eval & skill hygiene**; handoff Success criteria + Verify + NEVER; both skill copies synced; mid stays claude-ds. Docs-only, no version bump. |
|
|
73
75
|
| 2026-08-18 | Health `lane:mid` + `lanes.mid` prove DeepSeek; status evidence tree (`verify.claim` stays unverified); skill health-before-mid + closeout tree; mid stays claude-ds → 0.1.9 LIVE. |
|
|
74
|
-
| 2026-08-21 | Opt-in `--worker opencode` (Zen free `opencode/big-pickle`, `--auto`, no config rewrite); mid stays claude-ds → 0.1.10. |
|
|
76
|
+
| 2026-08-21 | Opt-in `--worker opencode` (Zen free `opencode/big-pickle`, `--auto`, no config rewrite); mid stays claude-ds → 0.1.10 LIVE. |
|
|
77
|
+
| 2026-08-21 | Live Zen free pick (Ox Alpha first while listed; OpenRouter-style catalog rank) → 0.1.11 LIVE. |
|
package/llms.txt
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> Cursor stays the planner. DeepSeek (mid), Grok CLI (hard), and OpenRouter free models (easy) run parallel coding workers in tmux. `--worker opencode` is an opt-in coding agent on OpenCode Zen free models.
|
|
4
4
|
|
|
5
|
-
MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.
|
|
5
|
+
MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.11**)
|
|
6
6
|
GitHub: https://github.com/cemini23/cursor-route
|
|
7
7
|
|
|
8
8
|
## FAQ
|
|
9
9
|
|
|
10
10
|
### What is cursor-route?
|
|
11
|
-
cursor-route is a public MIT CLI and Cursor skill that runs parallel coding workers in tmux while Cursor remains the planner. DeepSeek handles the mid lane, Grok CLI handles the hard lane, and OpenRouter free models handle the easy lane (wording/drafts, non-secret prompts only). `--worker opencode` is an opt-in coding agent (
|
|
11
|
+
cursor-route is a public MIT CLI and Cursor skill that runs parallel coding workers in tmux while Cursor remains the planner. DeepSeek handles the mid lane, Grok CLI handles the hard lane, and OpenRouter free models handle the easy lane (wording/drafts, non-secret prompts only). `--worker opencode` is an opt-in coding agent (`--model free` ranks the live Zen catalog; Ox Alpha wins while listed) — not a lane default.
|
|
12
12
|
|
|
13
13
|
### How is this different from Codex orchestrator?
|
|
14
14
|
It uses the familiar strategist and worker-pane shape, but it is not a Codex clone. cursor-route uses Cursor as the planner and DeepSeek plus Grok CLI as workers. Codex is not required.
|
|
@@ -23,7 +23,7 @@ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANT
|
|
|
23
23
|
| `--model pro` | `deepseek-v4-pro` | Harder mid / Grok usage stand-in |
|
|
24
24
|
|
|
25
25
|
### How do I use OpenCode free models?
|
|
26
|
-
Install OpenCode (`npm i -g opencode-ai`), run `opencode auth login`, then `cursor-route start --worker opencode "…"`.
|
|
26
|
+
Install OpenCode (`npm i -g opencode-ai`), run `opencode auth login`, then `cursor-route start --worker opencode "…"`. `--model free` ranks live Zen free models (Ox Alpha first while listed). Mid stays `claude-ds`.
|
|
27
27
|
|
|
28
28
|
### How do I install?
|
|
29
29
|
Run `npm i -g cursor-route`, install tmux if needed, then run `cursor-route health`. Copy the Cursor skill from `$(npm root -g)/cursor-route/skills/route-orch`. Source: https://github.com/cemini23/cursor-route
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-route",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter easy lane + OpenCode (opt-in free) are the parallel army \u2014 lane-aware /route orchestration in tmux.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -71,7 +71,7 @@ cursor-route start --worker opencode --model free --dir "$PWD" "…"
|
|
|
71
71
|
cursor-route start --worker opencode --model opencode/hy3-free --dir "$PWD" "…"
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Health ✓ needs `opencode` on PATH (override `CURSOR_ROUTE_OPENCODE_BIN`).
|
|
74
|
+
Health ✓ needs `opencode` on PATH (override `CURSOR_ROUTE_OPENCODE_BIN`). `--model free` / unset ranks the live OpenCode Zen catalog (Ox Alpha first while listed; pin with `CURSOR_ROUTE_OPENCODE_MODEL` or `--model provider/model`). Always-approve → `opencode run --auto`; `--ask` omits `--auto`. Never rewrites `~/.config/opencode/opencode.json`. Free Zen models may log/train — keep secrets off this worker (same refuse gate as easy). Ox Alpha (`opencode/x-preview-f-free`) is the zero-retention free option.
|
|
75
75
|
|
|
76
76
|
## Workflow
|
|
77
77
|
|
|
@@ -11,16 +11,36 @@ import {
|
|
|
11
11
|
} from "../config.ts";
|
|
12
12
|
|
|
13
13
|
describe("openCodeModel", () => {
|
|
14
|
-
test("defaults to
|
|
15
|
-
const prev =
|
|
14
|
+
test("defaults to live Zen pick (Ox Alpha fallback when offline)", () => {
|
|
15
|
+
const prev = {
|
|
16
|
+
model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
|
|
17
|
+
offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
|
|
18
|
+
cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
|
|
19
|
+
json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
|
|
20
|
+
refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
|
|
21
|
+
};
|
|
22
|
+
const cache = join(tmpdir(), `cr-oc-model-${process.pid}.json`);
|
|
16
23
|
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
24
|
+
delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
25
|
+
process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
|
|
26
|
+
process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = cache;
|
|
27
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
|
|
17
28
|
try {
|
|
18
|
-
expect(openCodeModel()).toBe("opencode/
|
|
29
|
+
expect(openCodeModel()).toBe("opencode/x-preview-f-free");
|
|
19
30
|
expect(openCodeModel("")).toBe(OPENCODE_DEFAULT_MODEL);
|
|
20
|
-
expect(openCodeModel("free")).toBe("opencode/
|
|
31
|
+
expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
|
|
21
32
|
} finally {
|
|
22
|
-
if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
23
|
-
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
|
|
33
|
+
if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
34
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
|
|
35
|
+
if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_ZEN_OFFLINE;
|
|
36
|
+
else process.env.CURSOR_ROUTE_ZEN_OFFLINE = prev.offline;
|
|
37
|
+
if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_ZEN_CACHE_PATH;
|
|
38
|
+
else process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = prev.cache;
|
|
39
|
+
if (prev.json === undefined) delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
40
|
+
else process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = prev.json;
|
|
41
|
+
if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_ZEN_REFRESH;
|
|
42
|
+
else process.env.CURSOR_ROUTE_ZEN_REFRESH = prev.refresh;
|
|
43
|
+
rmSync(cache, { force: true });
|
|
24
44
|
}
|
|
25
45
|
});
|
|
26
46
|
|
|
@@ -100,14 +120,21 @@ describe("opencode adapter", () => {
|
|
|
100
120
|
});
|
|
101
121
|
|
|
102
122
|
test("health: binary → ok (auth at first start)", () => {
|
|
103
|
-
const { bin } = makeFake("ok");
|
|
104
|
-
withEnv(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
const { dir, bin } = makeFake("ok");
|
|
124
|
+
withEnv(
|
|
125
|
+
{
|
|
126
|
+
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
127
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "missing-cache.json"),
|
|
128
|
+
},
|
|
129
|
+
() => {
|
|
130
|
+
const h = opencodeAdapter.health();
|
|
131
|
+
expect(h.ok).toBe(true);
|
|
132
|
+
expect(h.binary).toBe(bin);
|
|
133
|
+
expect(h.detail).toContain("live Zen pick");
|
|
134
|
+
expect(h.detail).toContain("opencode/x-preview-f-free");
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
138
|
});
|
|
112
139
|
|
|
113
140
|
test("buildLaunch: default free model + --auto; prompt via cat", () => {
|
|
@@ -119,6 +146,10 @@ describe("opencode adapter", () => {
|
|
|
119
146
|
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
120
147
|
CURSOR_ROUTE_OPENCODE_MODEL: undefined,
|
|
121
148
|
CURSOR_ROUTE_ASK: undefined,
|
|
149
|
+
CURSOR_ROUTE_ZEN_OFFLINE: "1",
|
|
150
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
|
|
151
|
+
CURSOR_ROUTE_ZEN_REFRESH: "1",
|
|
152
|
+
CURSOR_ROUTE_ZEN_CATALOG_JSON: undefined,
|
|
122
153
|
},
|
|
123
154
|
() => {
|
|
124
155
|
const plan = opencodeAdapter.buildLaunch({
|
|
@@ -128,7 +159,7 @@ describe("opencode adapter", () => {
|
|
|
128
159
|
});
|
|
129
160
|
expect(plan.command).toContain("run");
|
|
130
161
|
expect(plan.command).toContain("--auto");
|
|
131
|
-
expect(plan.command).toContain("opencode/
|
|
162
|
+
expect(plan.command).toContain("opencode/x-preview-f-free");
|
|
132
163
|
expect(plan.command).toContain("$(cat");
|
|
133
164
|
expect(plan.command).toContain("--dir");
|
|
134
165
|
expect(plan.alwaysApprove).toBe(true);
|
|
@@ -160,15 +191,24 @@ describe("opencode adapter", () => {
|
|
|
160
191
|
const { dir, bin } = makeFake("ask-env");
|
|
161
192
|
const promptFile = join(dir, "job.prompt");
|
|
162
193
|
writeFileSync(promptFile, "ping");
|
|
163
|
-
withEnv(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
194
|
+
withEnv(
|
|
195
|
+
{
|
|
196
|
+
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
197
|
+
CURSOR_ROUTE_ASK: "1",
|
|
198
|
+
CURSOR_ROUTE_ZEN_OFFLINE: "1",
|
|
199
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
|
|
200
|
+
CURSOR_ROUTE_ZEN_REFRESH: "1",
|
|
201
|
+
},
|
|
202
|
+
() => {
|
|
203
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
204
|
+
promptFile,
|
|
205
|
+
cwd: dir,
|
|
206
|
+
alwaysApprove: true,
|
|
207
|
+
});
|
|
208
|
+
expect(plan.command).not.toContain("--auto");
|
|
209
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
210
|
+
},
|
|
211
|
+
);
|
|
172
212
|
rmSync(dir, { recursive: true, force: true });
|
|
173
213
|
});
|
|
174
214
|
|
|
@@ -183,6 +223,9 @@ describe("opencode adapter", () => {
|
|
|
183
223
|
CURSOR_ROUTE_OPENCODE_BIN: undefined,
|
|
184
224
|
PATH: empty,
|
|
185
225
|
CURSOR_ROUTE_OPENCODE_MODEL: undefined,
|
|
226
|
+
CURSOR_ROUTE_ZEN_OFFLINE: "1",
|
|
227
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
|
|
228
|
+
CURSOR_ROUTE_ZEN_REFRESH: "1",
|
|
186
229
|
},
|
|
187
230
|
() => {
|
|
188
231
|
const plan = opencodeAdapter.buildLaunch({
|
|
@@ -208,10 +251,18 @@ describe("opencode adapter", () => {
|
|
|
208
251
|
bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
|
|
209
252
|
model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
|
|
210
253
|
jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
|
|
254
|
+
offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
|
|
255
|
+
cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
|
|
256
|
+
refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
|
|
257
|
+
json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
|
|
211
258
|
};
|
|
212
259
|
process.env.CURSOR_ROUTE_OPENCODE_BIN = bin;
|
|
213
260
|
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
261
|
+
delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
214
262
|
process.env.CURSOR_ROUTE_JOBS_DIR = join(dir, "jobs");
|
|
263
|
+
process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
|
|
264
|
+
process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = join(dir, "zen-cache.json");
|
|
265
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
|
|
215
266
|
try {
|
|
216
267
|
const result = startJob({
|
|
217
268
|
prompt: "ping",
|
|
@@ -221,7 +272,7 @@ describe("opencode adapter", () => {
|
|
|
221
272
|
expect(result.ok).toBe(true);
|
|
222
273
|
if (!result.ok) return;
|
|
223
274
|
expect(result.job.worker).toBe("opencode");
|
|
224
|
-
expect(result.job.model).toBe("opencode/
|
|
275
|
+
expect(result.job.model).toBe("opencode/x-preview-f-free");
|
|
225
276
|
expect(result.command).toContain("run");
|
|
226
277
|
expect(result.command).toContain("--auto");
|
|
227
278
|
expect(result.command).toContain("$(cat");
|
|
@@ -232,6 +283,14 @@ describe("opencode adapter", () => {
|
|
|
232
283
|
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
|
|
233
284
|
if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
|
|
234
285
|
else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
|
|
286
|
+
if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_ZEN_OFFLINE;
|
|
287
|
+
else process.env.CURSOR_ROUTE_ZEN_OFFLINE = prev.offline;
|
|
288
|
+
if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_ZEN_CACHE_PATH;
|
|
289
|
+
else process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = prev.cache;
|
|
290
|
+
if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_ZEN_REFRESH;
|
|
291
|
+
else process.env.CURSOR_ROUTE_ZEN_REFRESH = prev.refresh;
|
|
292
|
+
if (prev.json === undefined) delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
293
|
+
else process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = prev.json;
|
|
235
294
|
rmSync(dir, { recursive: true, force: true });
|
|
236
295
|
}
|
|
237
296
|
});
|
package/src/adapters/opencode.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { execSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import type { Adapter, WorkerHealth } from "./types.ts";
|
|
4
4
|
import { shellQuote } from "../util.ts";
|
|
5
|
-
import { openCodeModel, OPENCODE_DEFAULT_MODEL } from "../config.ts";
|
|
5
|
+
import { openCodeModel, OPENCODE_DEFAULT_MODEL, cachedZenFreePick } from "../config.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Opt-in OpenCode worker (`opencode run`) — a coding agent
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Opt-in OpenCode worker (`opencode run`) — a coding agent on live OpenCode
|
|
9
|
+
* Zen free models (`--model free` ranks the catalog; Ox Alpha wins while
|
|
10
|
+
* listed). Not a lane default: mid stays claude-ds; easy stays OpenRouter
|
|
11
|
+
* chat (no tools).
|
|
12
12
|
*
|
|
13
13
|
* We never rewrite ~/.config/opencode/opencode.json (parallel jobs would
|
|
14
14
|
* race). Always-approve maps to `opencode run --auto` (still honors explicit
|
|
@@ -47,17 +47,13 @@ export const opencodeAdapter: Adapter = {
|
|
|
47
47
|
"opencode not found — install: npm i -g opencode-ai (or brew install opencode). Then: opencode auth login. Mid default remains claude-ds.",
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
defaultModel = openCodeModel();
|
|
53
|
-
} catch {
|
|
54
|
-
/* invalid env — health still ok; startJob will fail loud */
|
|
55
|
-
}
|
|
50
|
+
// Health stays offline: show a fresh cache hit, else the Ox Alpha fallback.
|
|
51
|
+
const pick = cachedZenFreePick() || OPENCODE_DEFAULT_MODEL;
|
|
56
52
|
return {
|
|
57
53
|
worker: "opencode",
|
|
58
54
|
ok: true,
|
|
59
55
|
binary,
|
|
60
|
-
detail: `ok (opencode run;
|
|
56
|
+
detail: `ok (opencode run; --model free = live Zen pick, now ${pick}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
|
|
61
57
|
};
|
|
62
58
|
},
|
|
63
59
|
buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
|
package/src/cli.test.ts
CHANGED
|
@@ -618,7 +618,7 @@ describe("health", () => {
|
|
|
618
618
|
test("returns structured report", () => {
|
|
619
619
|
const r = runHealth();
|
|
620
620
|
expect(r.product).toBe("cursor-route");
|
|
621
|
-
expect(r.version).toBe("0.1.
|
|
621
|
+
expect(r.version).toBe("0.1.11");
|
|
622
622
|
expect(r.checks.length).toBeGreaterThan(3);
|
|
623
623
|
expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
|
|
624
624
|
expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
|
|
@@ -630,8 +630,8 @@ describe("health", () => {
|
|
|
630
630
|
expect(r.checks.some((c) => c.name === "worker:deepseek")).toBe(true);
|
|
631
631
|
});
|
|
632
632
|
|
|
633
|
-
test("config version is 0.1.
|
|
634
|
-
expect(config.version).toBe("0.1.
|
|
633
|
+
test("config version is 0.1.11", () => {
|
|
634
|
+
expect(config.version).toBe("0.1.11");
|
|
635
635
|
});
|
|
636
636
|
|
|
637
637
|
test("OR-gate: ok can be true while worker:opencode is false", () => {
|
package/src/cli.ts
CHANGED
|
@@ -56,7 +56,7 @@ Usage:
|
|
|
56
56
|
Start options:
|
|
57
57
|
--worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
|
|
58
58
|
--lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
|
|
59
|
-
--model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (
|
|
59
|
+
--model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (live Zen catalog pick) or provider/model
|
|
60
60
|
--dir <path> Working directory (default: cwd)
|
|
61
61
|
--ask Disable always-approve for this job
|
|
62
62
|
--dry-run Print launch command; do not start
|
|
@@ -78,7 +78,7 @@ Env:
|
|
|
78
78
|
OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
|
|
79
79
|
CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
|
|
80
80
|
OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
|
|
81
|
-
CURSOR_ROUTE_OPENCODE_MODEL OpenCode model (
|
|
81
|
+
CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
|
|
82
82
|
`);
|
|
83
83
|
process.exit(exitCode);
|
|
84
84
|
}
|
|
@@ -177,7 +177,7 @@ function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | unde
|
|
|
177
177
|
function asOpenCodeModel(v: unknown): string | undefined {
|
|
178
178
|
if (v === undefined || v === true) return undefined;
|
|
179
179
|
if (typeof v !== "string") {
|
|
180
|
-
throw new Error(`Invalid --model; expected provider/model (e.g. opencode/
|
|
180
|
+
throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
|
|
181
181
|
}
|
|
182
182
|
return openCodeModel(v);
|
|
183
183
|
}
|
package/src/config.ts
CHANGED
|
@@ -74,36 +74,12 @@ export function openRouterBaseUrl(): string {
|
|
|
74
74
|
return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/** Whitelist OpenCode `provider/model` ids (no spaces / injection). */
|
|
85
|
-
export function assertOpenCodeModel(id: string): string {
|
|
86
|
-
if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/big-pickle) or free`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
return id;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Resolve OpenCode `--model` / env to a concrete `provider/model` id.
|
|
96
|
-
* Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL, else OPENCODE_DEFAULT_MODEL.
|
|
97
|
-
*/
|
|
98
|
-
export function openCodeModel(raw?: string | null): string {
|
|
99
|
-
const v = (raw ?? "").trim();
|
|
100
|
-
if (!v || v.toLowerCase() === "free") {
|
|
101
|
-
const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
|
|
102
|
-
if (!env || env.toLowerCase() === "free") return OPENCODE_DEFAULT_MODEL;
|
|
103
|
-
return assertOpenCodeModel(env);
|
|
104
|
-
}
|
|
105
|
-
return assertOpenCodeModel(v);
|
|
106
|
-
}
|
|
77
|
+
export {
|
|
78
|
+
OPENCODE_DEFAULT_MODEL,
|
|
79
|
+
assertOpenCodeModel,
|
|
80
|
+
openCodeModel,
|
|
81
|
+
cachedZenFreePick,
|
|
82
|
+
} from "./zen-free.ts";
|
|
107
83
|
|
|
108
84
|
function maxConcurrentJobsFromEnv(): number {
|
|
109
85
|
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
@@ -121,7 +97,7 @@ function maxConcurrentJobsFromEnv(): number {
|
|
|
121
97
|
*/
|
|
122
98
|
export const config = {
|
|
123
99
|
product: "cursor-route",
|
|
124
|
-
version: "0.1.
|
|
100
|
+
version: "0.1.11",
|
|
125
101
|
get jobsDir(): string {
|
|
126
102
|
return defaultJobsDir();
|
|
127
103
|
},
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
asOpenCodeZenId,
|
|
7
|
+
isZenFreeModel,
|
|
8
|
+
openCodeModel,
|
|
9
|
+
OPENCODE_DEFAULT_MODEL,
|
|
10
|
+
pickZenFreeModel,
|
|
11
|
+
rankZenFreeModels,
|
|
12
|
+
zenFreeBoost,
|
|
13
|
+
type ZenModel,
|
|
14
|
+
} from "./zen-free.ts";
|
|
15
|
+
|
|
16
|
+
const CATALOG: ZenModel[] = [
|
|
17
|
+
{ id: "big-pickle", name: "Big Pickle" },
|
|
18
|
+
{ id: "hy3-free", name: "HY3 Free" },
|
|
19
|
+
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
|
20
|
+
{ id: "muse-spark-1.2-contributor-free", name: "Muse Spark Contributor" },
|
|
21
|
+
{ id: "laguna-s-2.1-free", name: "Laguna" },
|
|
22
|
+
{ id: "x-preview-f-free", name: "Ox Alpha Free" },
|
|
23
|
+
{ id: "whisper-free", name: "Whisper" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe("zen free catalog", () => {
|
|
27
|
+
test("tags -free / big-pickle as free; skips paid and audio", () => {
|
|
28
|
+
expect(isZenFreeModel({ id: "x-preview-f-free" })).toBe(true);
|
|
29
|
+
expect(isZenFreeModel({ id: "big-pickle" })).toBe(true);
|
|
30
|
+
expect(isZenFreeModel({ id: "openrouter/qwen/qwen3-coder:free" })).toBe(true);
|
|
31
|
+
expect(isZenFreeModel({ id: "claude-opus-4-6" })).toBe(false);
|
|
32
|
+
expect(isZenFreeModel({ id: "whisper-free" })).toBe(false);
|
|
33
|
+
expect(
|
|
34
|
+
isZenFreeModel({
|
|
35
|
+
id: "some-zero-price",
|
|
36
|
+
pricing: { prompt: 0, completion: 0 },
|
|
37
|
+
}),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("Ox Alpha outranks coding free, which outranks big-pickle and contributor", () => {
|
|
42
|
+
expect(zenFreeBoost("opencode/x-preview-f-free")).toBe(40);
|
|
43
|
+
expect(zenFreeBoost("opencode/laguna-s-2.1-free")).toBe(20);
|
|
44
|
+
expect(zenFreeBoost("opencode/hy3-free")).toBe(20);
|
|
45
|
+
expect(zenFreeBoost("opencode/big-pickle")).toBe(5);
|
|
46
|
+
expect(zenFreeBoost("opencode/muse-spark-1.2-contributor-free")).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rank: Ox Alpha first while listed; paid/whisper dropped", () => {
|
|
50
|
+
const ranked = rankZenFreeModels(CATALOG);
|
|
51
|
+
expect(ranked[0]?.id).toBe("opencode/x-preview-f-free");
|
|
52
|
+
expect(ranked.map((r) => r.id)).not.toContain("opencode/claude-opus-4-6");
|
|
53
|
+
expect(ranked.map((r) => r.id)).not.toContain("opencode/whisper-free");
|
|
54
|
+
expect(ranked.at(-1)?.id).toBe("opencode/muse-spark-1.2-contributor-free");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rank without Ox: coding free beats big-pickle", () => {
|
|
58
|
+
const ranked = rankZenFreeModels([
|
|
59
|
+
{ id: "big-pickle" },
|
|
60
|
+
{ id: "laguna-s-2.1-free" },
|
|
61
|
+
{ id: "muse-spark-1.2-contributor-free" },
|
|
62
|
+
]);
|
|
63
|
+
expect(ranked[0]?.id).toBe("opencode/laguna-s-2.1-free");
|
|
64
|
+
expect(ranked[1]?.id).toBe("opencode/big-pickle");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("asOpenCodeZenId prefixes bare Zen ids", () => {
|
|
68
|
+
expect(asOpenCodeZenId("x-preview-f-free")).toBe("opencode/x-preview-f-free");
|
|
69
|
+
expect(asOpenCodeZenId("opencode/big-pickle")).toBe("opencode/big-pickle");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("pickZenFreeModel uses the ranked catalog winner", () => {
|
|
73
|
+
expect(pickZenFreeModel({ catalog: CATALOG })).toBe("opencode/x-preview-f-free");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("offline / empty catalog falls back to Ox Alpha", () => {
|
|
77
|
+
expect(pickZenFreeModel({ catalog: [] })).toBe(OPENCODE_DEFAULT_MODEL);
|
|
78
|
+
expect(OPENCODE_DEFAULT_MODEL).toBe("opencode/x-preview-f-free");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("openCodeModel live pick", () => {
|
|
83
|
+
const isolate = (fn: () => void) => {
|
|
84
|
+
const dir = join(tmpdir(), `cr-zen-${process.pid}-${Math.random().toString(16).slice(2)}`);
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
const prev = {
|
|
87
|
+
model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
|
|
88
|
+
offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
|
|
89
|
+
json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
|
|
90
|
+
cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
|
|
91
|
+
refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
|
|
92
|
+
};
|
|
93
|
+
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
94
|
+
process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = join(dir, "cache.json");
|
|
95
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
|
|
96
|
+
try {
|
|
97
|
+
fn();
|
|
98
|
+
} finally {
|
|
99
|
+
for (const [k, v] of Object.entries(prev)) {
|
|
100
|
+
const envKey = {
|
|
101
|
+
model: "CURSOR_ROUTE_OPENCODE_MODEL",
|
|
102
|
+
offline: "CURSOR_ROUTE_ZEN_OFFLINE",
|
|
103
|
+
json: "CURSOR_ROUTE_ZEN_CATALOG_JSON",
|
|
104
|
+
cache: "CURSOR_ROUTE_ZEN_CACHE_PATH",
|
|
105
|
+
refresh: "CURSOR_ROUTE_ZEN_REFRESH",
|
|
106
|
+
}[k]!;
|
|
107
|
+
if (v === undefined) delete process.env[envKey];
|
|
108
|
+
else process.env[envKey] = v;
|
|
109
|
+
}
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
test("free/empty + offline → Ox Alpha fallback", () => {
|
|
115
|
+
isolate(() => {
|
|
116
|
+
process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
|
|
117
|
+
expect(openCodeModel()).toBe("opencode/x-preview-f-free");
|
|
118
|
+
expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("free + injected catalog → Ox Alpha", () => {
|
|
123
|
+
isolate(() => {
|
|
124
|
+
process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = JSON.stringify({ data: CATALOG });
|
|
125
|
+
expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("CURSOR_ROUTE_OPENCODE_MODEL pin wins over catalog", () => {
|
|
130
|
+
isolate(() => {
|
|
131
|
+
process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
|
|
132
|
+
process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = JSON.stringify({ data: CATALOG });
|
|
133
|
+
expect(openCodeModel()).toBe("opencode/hy3-free");
|
|
134
|
+
expect(openCodeModel("free")).toBe("opencode/hy3-free");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/zen-free.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live OpenCode Zen free-model pick — same idea as agent-toolkit
|
|
8
|
+
* `select-openrouter-free-model.ps1`: probe the catalog, keep $0 text/coding
|
|
9
|
+
* models, rank, cache ~15 min. Hardcoded ids are fallback only.
|
|
10
|
+
*
|
|
11
|
+
* Catalog: GET https://opencode.ai/zen/v1/models (no key). Zen ids are
|
|
12
|
+
* `big-pickle` / `x-preview-f-free`; the CLI wants `opencode/<id>`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const ZEN_MODELS_URL_DEFAULT = "https://opencode.ai/zen/v1/models";
|
|
16
|
+
|
|
17
|
+
/** Offline / fetch-fail fallback — Ox Alpha (zero-retention, currently top-tier free). */
|
|
18
|
+
export const OPENCODE_DEFAULT_MODEL = "opencode/x-preview-f-free";
|
|
19
|
+
|
|
20
|
+
export interface ZenModel {
|
|
21
|
+
id?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
context_length?: number;
|
|
24
|
+
pricing?: { prompt?: number | string; completion?: number | string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ZenFreePick {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
context_length: number;
|
|
31
|
+
boost: number;
|
|
32
|
+
picked_at: string;
|
|
33
|
+
candidates: number;
|
|
34
|
+
source: "ranked-free" | "fallback";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EXCLUDE_RE =
|
|
38
|
+
/lyria|whisper|tts|embed|embedding|image|vision-only|audio|diffusion|flux|stable-diffusion|moderation/i;
|
|
39
|
+
const CODING_RE =
|
|
40
|
+
/coder|instruct|chat|laguna|nemotron|glm|qwen|kimi|deepseek|mimo|hy3|gpt-oss|north|gemma/i;
|
|
41
|
+
const OX_ALPHA_RE = /x-preview|ox-alpha|oxalpha/i;
|
|
42
|
+
const CONTRIBUTOR_RE = /contributor-free/i;
|
|
43
|
+
|
|
44
|
+
function zenModelsUrl(): string {
|
|
45
|
+
return (process.env.CURSOR_ROUTE_ZEN_MODELS_URL || ZEN_MODELS_URL_DEFAULT).trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cacheMinutes(): number {
|
|
49
|
+
const n = Number(process.env.CURSOR_ROUTE_ZEN_CACHE_MINUTES);
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? n : 15;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cachePath(): string {
|
|
54
|
+
return process.env.CURSOR_ROUTE_ZEN_CACHE_PATH?.trim() ||
|
|
55
|
+
join(tmpdir(), "cursor-route-zen-best-free.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function assertOpenCodeModel(id: string): string {
|
|
59
|
+
if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/x-preview-f-free) or free`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Prefix a Zen catalog id (`x-preview-f-free`) for `opencode run --model`. */
|
|
68
|
+
export function asOpenCodeZenId(raw: string): string {
|
|
69
|
+
const id = raw.trim();
|
|
70
|
+
if (!id) throw new Error("empty Zen model id");
|
|
71
|
+
return id.includes("/") ? assertOpenCodeModel(id) : assertOpenCodeModel(`opencode/${id}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function num(v: unknown): number {
|
|
75
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
76
|
+
if (typeof v === "string" && v.trim()) {
|
|
77
|
+
const n = Number(v);
|
|
78
|
+
if (Number.isFinite(n)) return n;
|
|
79
|
+
}
|
|
80
|
+
return NaN;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isZenFreeModel(m: ZenModel): boolean {
|
|
84
|
+
const id = (m.id || "").trim();
|
|
85
|
+
if (!id) return false;
|
|
86
|
+
if (EXCLUDE_RE.test(id) || EXCLUDE_RE.test(m.name || "")) return false;
|
|
87
|
+
const prompt = num(m.pricing?.prompt);
|
|
88
|
+
const completion = num(m.pricing?.completion);
|
|
89
|
+
const pricedFree = prompt === 0 && completion === 0;
|
|
90
|
+
const tagged =
|
|
91
|
+
id.endsWith("-free") ||
|
|
92
|
+
id.endsWith(":free") ||
|
|
93
|
+
/(^|\/)big-pickle$/i.test(id);
|
|
94
|
+
return tagged || pricedFree;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Higher boost wins. Ox Alpha first while it is in the free catalog. */
|
|
98
|
+
export function zenFreeBoost(id: string): number {
|
|
99
|
+
const s = id.toLowerCase();
|
|
100
|
+
if (OX_ALPHA_RE.test(s)) return 40;
|
|
101
|
+
if (CONTRIBUTOR_RE.test(s)) return 1;
|
|
102
|
+
if (CODING_RE.test(s)) return 20;
|
|
103
|
+
if (s.endsWith("-free") || s.endsWith(":free")) return 10;
|
|
104
|
+
if (/(^|\/)big-pickle$/.test(s)) return 5;
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function rankZenFreeModels(models: ZenModel[]): Array<{
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
context_length: number;
|
|
112
|
+
boost: number;
|
|
113
|
+
}> {
|
|
114
|
+
const out: Array<{ id: string; name: string; context_length: number; boost: number }> = [];
|
|
115
|
+
for (const m of models) {
|
|
116
|
+
if (!isZenFreeModel(m)) continue;
|
|
117
|
+
const raw = (m.id || "").trim();
|
|
118
|
+
let id: string;
|
|
119
|
+
try {
|
|
120
|
+
id = asOpenCodeZenId(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const ctx = Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : 0;
|
|
125
|
+
out.push({
|
|
126
|
+
id,
|
|
127
|
+
name: (m.name || raw).trim(),
|
|
128
|
+
context_length: ctx,
|
|
129
|
+
boost: zenFreeBoost(id),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
out.sort((a, b) => {
|
|
133
|
+
if (b.boost !== a.boost) return b.boost - a.boost;
|
|
134
|
+
if (b.context_length !== a.context_length) return b.context_length - a.context_length;
|
|
135
|
+
return a.id.localeCompare(b.id);
|
|
136
|
+
});
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readCache(): ZenFreePick | null {
|
|
141
|
+
const p = cachePath();
|
|
142
|
+
if (!existsSync(p)) return null;
|
|
143
|
+
try {
|
|
144
|
+
const j = JSON.parse(readFileSync(p, "utf8")) as ZenFreePick;
|
|
145
|
+
if (!j?.id) return null;
|
|
146
|
+
const ageMs = Date.now() - Date.parse(j.picked_at);
|
|
147
|
+
if (!Number.isFinite(ageMs) || ageMs < 0) return null;
|
|
148
|
+
if (ageMs > cacheMinutes() * 60_000) return null;
|
|
149
|
+
assertOpenCodeModel(j.id);
|
|
150
|
+
return j;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeCache(pick: ZenFreePick): void {
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(cachePath(), JSON.stringify(pick), { mode: 0o600 });
|
|
159
|
+
} catch {
|
|
160
|
+
/* cache is optional */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function httpGetSync(url: string, timeoutMs = 4000): string | null {
|
|
165
|
+
const sec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
166
|
+
const curl = spawnSync(
|
|
167
|
+
"curl",
|
|
168
|
+
["-fsS", "--max-time", String(sec), "-H", "Accept: application/json", url],
|
|
169
|
+
{ encoding: "utf8", timeout: timeoutMs + 500, env: { ...process.env } },
|
|
170
|
+
);
|
|
171
|
+
if (curl.status === 0 && curl.stdout?.trim()) return curl.stdout;
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseCatalog(raw: string): ZenModel[] {
|
|
176
|
+
const j = JSON.parse(raw) as { data?: ZenModel[] } | ZenModel[];
|
|
177
|
+
if (Array.isArray(j)) return j;
|
|
178
|
+
if (Array.isArray(j.data)) return j.data;
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function loadZenCatalog(): ZenModel[] {
|
|
183
|
+
const inline = process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON?.trim();
|
|
184
|
+
if (inline) return parseCatalog(inline);
|
|
185
|
+
if (process.env.CURSOR_ROUTE_ZEN_OFFLINE === "1") return [];
|
|
186
|
+
const body = httpGetSync(zenModelsUrl());
|
|
187
|
+
if (!body) return [];
|
|
188
|
+
try {
|
|
189
|
+
return parseCatalog(body);
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Last live pick if the cache is still fresh — health-safe (no network). */
|
|
196
|
+
export function cachedZenFreePick(): string | null {
|
|
197
|
+
try {
|
|
198
|
+
return readCache()?.id ?? null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Best live Zen free model, or OPENCODE_DEFAULT_MODEL if the catalog is empty/offline.
|
|
206
|
+
* `CURSOR_ROUTE_ZEN_REFRESH=1` skips cache. Tests: `CURSOR_ROUTE_ZEN_OFFLINE=1` or
|
|
207
|
+
* `CURSOR_ROUTE_ZEN_CATALOG_JSON`.
|
|
208
|
+
*/
|
|
209
|
+
export function pickZenFreeModel(opts?: { catalog?: ZenModel[]; refresh?: boolean }): string {
|
|
210
|
+
const refresh =
|
|
211
|
+
opts?.refresh ||
|
|
212
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH === "1";
|
|
213
|
+
if (!refresh && opts?.catalog === undefined) {
|
|
214
|
+
const cached = readCache();
|
|
215
|
+
if (cached) return cached.id;
|
|
216
|
+
}
|
|
217
|
+
const models = opts?.catalog ?? loadZenCatalog();
|
|
218
|
+
const ranked = rankZenFreeModels(models);
|
|
219
|
+
const winner = ranked[0];
|
|
220
|
+
if (!winner) {
|
|
221
|
+
const fallback: ZenFreePick = {
|
|
222
|
+
id: OPENCODE_DEFAULT_MODEL,
|
|
223
|
+
name: "Ox Alpha Free (fallback)",
|
|
224
|
+
context_length: 0,
|
|
225
|
+
boost: 0,
|
|
226
|
+
picked_at: new Date().toISOString(),
|
|
227
|
+
candidates: 0,
|
|
228
|
+
source: "fallback",
|
|
229
|
+
};
|
|
230
|
+
if (opts?.catalog === undefined) writeCache(fallback);
|
|
231
|
+
return fallback.id;
|
|
232
|
+
}
|
|
233
|
+
const pick: ZenFreePick = {
|
|
234
|
+
id: winner.id,
|
|
235
|
+
name: winner.name,
|
|
236
|
+
context_length: winner.context_length,
|
|
237
|
+
boost: winner.boost,
|
|
238
|
+
picked_at: new Date().toISOString(),
|
|
239
|
+
candidates: ranked.length,
|
|
240
|
+
source: "ranked-free",
|
|
241
|
+
};
|
|
242
|
+
if (opts?.catalog === undefined) writeCache(pick);
|
|
243
|
+
return pick.id;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolve OpenCode `--model` / env to a concrete `provider/model` id.
|
|
248
|
+
* Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL pin, else live Zen free pick.
|
|
249
|
+
*/
|
|
250
|
+
export function openCodeModel(raw?: string | null): string {
|
|
251
|
+
const v = (raw ?? "").trim();
|
|
252
|
+
if (!v || v.toLowerCase() === "free") {
|
|
253
|
+
const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
|
|
254
|
+
if (env && env.toLowerCase() !== "free") return assertOpenCodeModel(env);
|
|
255
|
+
return pickZenFreeModel();
|
|
256
|
+
}
|
|
257
|
+
return assertOpenCodeModel(v);
|
|
258
|
+
}
|