cursor-route 0.1.9 → 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/src/jobs.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  sessionName,
18
18
  defaultDsModelFromEnv,
19
19
  DS_MODEL_IDS,
20
+ openCodeModel,
20
21
  type Lane,
21
22
  type WorkerKind,
22
23
  type DsModelAlias,
@@ -37,8 +38,8 @@ export interface Job {
37
38
  status: JobStatus;
38
39
  worker: WorkerKind;
39
40
  lane?: Lane;
40
- /** Mid-lane DeepSeek model alias (flash|pro). Set for claude-ds and deepseek. */
41
- model?: DsModelAlias;
41
+ /** Worker model: flash|pro for claude-ds/deepseek; provider/model for opencode. */
42
+ model?: string;
42
43
  prompt: string;
43
44
  cwd: string;
44
45
  alwaysApprove: boolean;
@@ -111,7 +112,7 @@ export interface JobEvidence {
111
112
  jobId: string;
112
113
  worker: WorkerKind;
113
114
  lane: Lane | null;
114
- model: DsModelAlias | null;
115
+ model: string | null;
115
116
  startedAt: string;
116
117
  };
117
118
  execute: {
@@ -282,7 +283,7 @@ export interface StartOptions {
282
283
  lane?: Lane;
283
284
  /** Mid-lane DeepSeek: flash (default) | pro (claude-ds + deepseek). Ignored by grok/openrouter. */
284
285
  model?: DsModelAlias;
285
- /** Concrete DeepSeek id (preserves pro[1m]). Derived from --model / env when unset. */
286
+ /** Concrete DeepSeek id (preserves pro[1m]) or OpenCode provider/model. Derived from --model / env when unset. */
286
287
  modelId?: string;
287
288
  cwd?: string;
288
289
  alwaysApprove?: boolean;
@@ -331,15 +332,18 @@ export function startJob(opts: StartOptions): {
331
332
  const paths = jobPaths(id);
332
333
  writeSecure(paths.prompt, opts.prompt);
333
334
 
334
- let model: DsModelAlias | undefined;
335
+ let model: string | undefined;
335
336
  let modelId: string | undefined;
337
+ let dsAlias: DsModelAlias | undefined;
336
338
  if (worker === "claude-ds" || worker === "deepseek") {
337
339
  if (opts.model) {
340
+ dsAlias = opts.model;
338
341
  model = opts.model;
339
342
  modelId = opts.modelId ?? DS_MODEL_IDS[opts.model];
340
343
  } else {
341
344
  try {
342
345
  const choice = defaultDsModelFromEnv();
346
+ dsAlias = choice.alias;
343
347
  model = choice.alias;
344
348
  modelId = opts.modelId ?? choice.id;
345
349
  } catch (e) {
@@ -351,6 +355,19 @@ export function startJob(opts: StartOptions): {
351
355
  return { ok: false, error: (e as Error).message };
352
356
  }
353
357
  }
358
+ } else if (worker === "opencode") {
359
+ try {
360
+ const ocModel = openCodeModel(opts.modelId);
361
+ model = ocModel;
362
+ modelId = ocModel;
363
+ } catch (e) {
364
+ try {
365
+ unlinkSync(paths.prompt);
366
+ } catch {
367
+ /* ignore */
368
+ }
369
+ return { ok: false, error: (e as Error).message };
370
+ }
354
371
  }
355
372
 
356
373
  let plan;
@@ -359,7 +376,7 @@ export function startJob(opts: StartOptions): {
359
376
  promptFile: paths.prompt,
360
377
  cwd,
361
378
  alwaysApprove,
362
- model,
379
+ model: dsAlias,
363
380
  modelId,
364
381
  dryRun: Boolean(opts.dryRun),
365
382
  });
@@ -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
+ });
@@ -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
+ }