cursor-route 0.1.10 → 0.1.13

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/cli.test.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
13
  import { resolveWorker, startJob, jobEvidence, type Job } from "./jobs.ts";
14
- import { config, resolveDsModel, resolveDsModelAlias } from "./config.ts";
14
+ import { config, promptLooksLikeVision, resolveDsModel, resolveDsModelAlias } from "./config.ts";
15
15
  import { shellQuote, newJobId } from "./util.ts";
16
16
  import { runHealth } from "./health.ts";
17
17
  import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
@@ -58,13 +58,37 @@ describe("resolveDsModel", () => {
58
58
  test("accepts aliases and full ids", () => {
59
59
  expect(resolveDsModel("flash").id).toBe("deepseek-v4-flash");
60
60
  expect(resolveDsModel("pro").id).toBe("deepseek-v4-pro");
61
+ expect(resolveDsModel("vision").id).toBe("deepseek-v4-flash-vision-exp");
62
+ expect(resolveDsModel("deepseek-v4-flash-vision-exp")).toEqual({
63
+ alias: "vision",
64
+ id: "deepseek-v4-flash-vision-exp",
65
+ });
61
66
  expect(resolveDsModel("deepseek-v4-pro[1m]")).toEqual({
62
67
  alias: "pro",
63
68
  id: "deepseek-v4-pro[1m]",
64
69
  });
65
70
  });
66
71
  test("rejects unknown", () => {
67
- expect(() => resolveDsModel("opus")).toThrow(/flash\|pro/);
72
+ expect(() => resolveDsModel("opus")).toThrow(/flash\|pro\|vision/);
73
+ });
74
+ });
75
+
76
+ describe("promptLooksLikeVision", () => {
77
+ test("matches screenshot/image/png/jpg/jpeg/webp/ui mock/multimodal/vision", () => {
78
+ expect(promptLooksLikeVision("describe this screenshot")).toBe(true);
79
+ expect(promptLooksLikeVision("Look at this screenshot.png and describe it")).toBe(true);
80
+ expect(promptLooksLikeVision("review photo.jpg")).toBe(true);
81
+ expect(promptLooksLikeVision("open mock.jpeg")).toBe(true);
82
+ expect(promptLooksLikeVision("asset.webp")).toBe(true);
83
+ expect(promptLooksLikeVision("ui mock of the settings page")).toBe(true);
84
+ expect(promptLooksLikeVision("multimodal caption")).toBe(true);
85
+ expect(promptLooksLikeVision("use vision for this")).toBe(true);
86
+ expect(promptLooksLikeVision("describe the image")).toBe(true);
87
+ });
88
+ test("plain coding prompts stay false", () => {
89
+ expect(promptLooksLikeVision("fix the unit test")).toBe(false);
90
+ expect(promptLooksLikeVision("Add a unit test for shellQuote")).toBe(false);
91
+ expect(promptLooksLikeVision("fix the ImageMagick wrapper")).toBe(false);
68
92
  });
69
93
  });
70
94
 
@@ -93,6 +117,23 @@ describe("claude-ds -Model", () => {
93
117
  else process.env.ANTHROPIC_MODEL = prevAm;
94
118
  }
95
119
  });
120
+ test("vision alias maps to deepseek-v4-flash-vision-exp", () => {
121
+ const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
122
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
123
+ try {
124
+ const plan = claudeDsAdapter.buildLaunch({
125
+ promptFile: "/tmp/p.prompt",
126
+ cwd: "/tmp",
127
+ alwaysApprove: true,
128
+ model: "vision",
129
+ });
130
+ expect(plan.command).toContain("deepseek-v4-flash-vision-exp");
131
+ expect(plan.command).not.toContain("deepseek-v4-flash'");
132
+ } finally {
133
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
134
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
135
+ }
136
+ });
96
137
  test("pro alias maps to deepseek-v4-pro", () => {
97
138
  const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
98
139
  process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
@@ -206,6 +247,201 @@ describe("startJob product path", () => {
206
247
  }
207
248
  });
208
249
 
250
+ test("invalid ANTHROPIC_MODEL names the env vars, not --model", () => {
251
+ const prev = {
252
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
253
+ ds: process.env.CURSOR_ROUTE_DS_MODEL,
254
+ am: process.env.ANTHROPIC_MODEL,
255
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
256
+ };
257
+ const jobsDir = join(tmpdir(), `cr-jobs-env-bad-${process.pid}`);
258
+ mkdirSync(jobsDir, { recursive: true });
259
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
260
+ delete process.env.CURSOR_ROUTE_DS_MODEL;
261
+ process.env.ANTHROPIC_MODEL = "deepseek-chat";
262
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
263
+ try {
264
+ const result = startJob({
265
+ prompt: "fix the unit test",
266
+ lane: "mid",
267
+ dryRun: true,
268
+ });
269
+ expect(result.ok).toBe(false);
270
+ if (result.ok) return;
271
+ expect(result.error).toMatch(/ANTHROPIC_MODEL/);
272
+ expect(result.error).toMatch(/deepseek-chat/);
273
+ expect(result.error).not.toMatch(/Invalid --model/);
274
+ } finally {
275
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
276
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
277
+ if (prev.ds === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
278
+ else process.env.CURSOR_ROUTE_DS_MODEL = prev.ds;
279
+ if (prev.am === undefined) delete process.env.ANTHROPIC_MODEL;
280
+ else process.env.ANTHROPIC_MODEL = prev.am;
281
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
282
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
283
+ rmSync(jobsDir, { recursive: true, force: true });
284
+ }
285
+ });
286
+
287
+ test("screenshot.png auto-picks vision when --model and env unset", () => {
288
+ const prev = {
289
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
290
+ ds: process.env.CURSOR_ROUTE_DS_MODEL,
291
+ am: process.env.ANTHROPIC_MODEL,
292
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
293
+ };
294
+ const jobsDir = join(tmpdir(), `cr-jobs-vision-${process.pid}`);
295
+ mkdirSync(jobsDir, { recursive: true });
296
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
297
+ delete process.env.CURSOR_ROUTE_DS_MODEL;
298
+ delete process.env.ANTHROPIC_MODEL;
299
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
300
+ try {
301
+ const result = startJob({
302
+ prompt: "describe screenshot.png",
303
+ lane: "mid",
304
+ dryRun: true,
305
+ });
306
+ expect(result.ok).toBe(true);
307
+ if (!result.ok) return;
308
+ expect(result.job.model).toBe("vision");
309
+ expect(result.command).toContain("deepseek-v4-flash-vision-exp");
310
+ } finally {
311
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
312
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
313
+ if (prev.ds === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
314
+ else process.env.CURSOR_ROUTE_DS_MODEL = prev.ds;
315
+ if (prev.am === undefined) delete process.env.ANTHROPIC_MODEL;
316
+ else process.env.ANTHROPIC_MODEL = prev.am;
317
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
318
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
319
+ rmSync(jobsDir, { recursive: true, force: true });
320
+ }
321
+ });
322
+
323
+ test("plain prompt stays flash when env unset", () => {
324
+ const prev = {
325
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
326
+ ds: process.env.CURSOR_ROUTE_DS_MODEL,
327
+ am: process.env.ANTHROPIC_MODEL,
328
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
329
+ };
330
+ const jobsDir = join(tmpdir(), `cr-jobs-flash-${process.pid}`);
331
+ mkdirSync(jobsDir, { recursive: true });
332
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
333
+ delete process.env.CURSOR_ROUTE_DS_MODEL;
334
+ delete process.env.ANTHROPIC_MODEL;
335
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
336
+ try {
337
+ const result = startJob({
338
+ prompt: "fix the unit test",
339
+ lane: "mid",
340
+ dryRun: true,
341
+ });
342
+ expect(result.ok).toBe(true);
343
+ if (!result.ok) return;
344
+ expect(result.job.model).toBe("flash");
345
+ expect(result.command).toContain("deepseek-v4-flash");
346
+ expect(result.command).not.toContain("deepseek-v4-flash-vision-exp");
347
+ } finally {
348
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
349
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
350
+ if (prev.ds === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
351
+ else process.env.CURSOR_ROUTE_DS_MODEL = prev.ds;
352
+ if (prev.am === undefined) delete process.env.ANTHROPIC_MODEL;
353
+ else process.env.ANTHROPIC_MODEL = prev.am;
354
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
355
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
356
+ rmSync(jobsDir, { recursive: true, force: true });
357
+ }
358
+ });
359
+
360
+ test("explicit --model flash wins over a vision-looking prompt", () => {
361
+ const prev = {
362
+ bin: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
363
+ ds: process.env.CURSOR_ROUTE_DS_MODEL,
364
+ am: process.env.ANTHROPIC_MODEL,
365
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
366
+ };
367
+ const jobsDir = join(tmpdir(), `cr-jobs-flashwin-${process.pid}`);
368
+ mkdirSync(jobsDir, { recursive: true });
369
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
370
+ delete process.env.CURSOR_ROUTE_DS_MODEL;
371
+ delete process.env.ANTHROPIC_MODEL;
372
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
373
+ try {
374
+ const result = startJob({
375
+ prompt: "Look at this screenshot.png and describe it",
376
+ lane: "mid",
377
+ model: "flash",
378
+ dryRun: true,
379
+ });
380
+ expect(result.ok).toBe(true);
381
+ if (!result.ok) return;
382
+ expect(result.job.model).toBe("flash");
383
+ expect(result.command).not.toContain("deepseek-v4-flash-vision-exp");
384
+ } finally {
385
+ if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
386
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev.bin;
387
+ if (prev.ds === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
388
+ else process.env.CURSOR_ROUTE_DS_MODEL = prev.ds;
389
+ if (prev.am === undefined) delete process.env.ANTHROPIC_MODEL;
390
+ else process.env.ANTHROPIC_MODEL = prev.am;
391
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
392
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
393
+ rmSync(jobsDir, { recursive: true, force: true });
394
+ }
395
+ });
396
+
397
+ test("easy lane dry-run offline records the OpenRouter fallback id", () => {
398
+ const prev = {
399
+ jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
400
+ model: process.env.CURSOR_ROUTE_OPENROUTER_MODEL,
401
+ offline: process.env.CURSOR_ROUTE_OR_OFFLINE,
402
+ json: process.env.CURSOR_ROUTE_OR_CATALOG_JSON,
403
+ cache: process.env.CURSOR_ROUTE_OR_CACHE_PATH,
404
+ refresh: process.env.CURSOR_ROUTE_OR_REFRESH,
405
+ key: process.env.OPENROUTER_API_KEY,
406
+ };
407
+ const jobsDir = join(tmpdir(), `cr-jobs-or-${process.pid}`);
408
+ mkdirSync(jobsDir, { recursive: true });
409
+ process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
410
+ delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
411
+ process.env.CURSOR_ROUTE_OR_OFFLINE = "1";
412
+ delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
413
+ process.env.CURSOR_ROUTE_OR_CACHE_PATH = join(jobsDir, "or-cache.json");
414
+ process.env.CURSOR_ROUTE_OR_REFRESH = "1";
415
+ delete process.env.OPENROUTER_API_KEY;
416
+ try {
417
+ const result = startJob({
418
+ prompt: "Rewrite this FAQ",
419
+ lane: "easy",
420
+ dryRun: true,
421
+ });
422
+ expect(result.ok).toBe(true);
423
+ if (!result.ok) return;
424
+ expect(result.job.worker).toBe("openrouter");
425
+ expect(result.job.model).toBe("openrouter/free");
426
+ } finally {
427
+ if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
428
+ else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
429
+ if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
430
+ else process.env.CURSOR_ROUTE_OPENROUTER_MODEL = prev.model;
431
+ if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_OR_OFFLINE;
432
+ else process.env.CURSOR_ROUTE_OR_OFFLINE = prev.offline;
433
+ if (prev.json === undefined) delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
434
+ else process.env.CURSOR_ROUTE_OR_CATALOG_JSON = prev.json;
435
+ if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_OR_CACHE_PATH;
436
+ else process.env.CURSOR_ROUTE_OR_CACHE_PATH = prev.cache;
437
+ if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_OR_REFRESH;
438
+ else process.env.CURSOR_ROUTE_OR_REFRESH = prev.refresh;
439
+ if (prev.key === undefined) delete process.env.OPENROUTER_API_KEY;
440
+ else process.env.OPENROUTER_API_KEY = prev.key;
441
+ rmSync(jobsDir, { recursive: true, force: true });
442
+ }
443
+ });
444
+
209
445
  test("--worker deepseek dry-run succeeds with fake dsh + key", () => {
210
446
  const dir = join(tmpdir(), `cr-dsh-start-${process.pid}`);
211
447
  mkdirSync(dir, { recursive: true });
@@ -377,6 +613,31 @@ describe("deepseek adapter (dsh)", () => {
377
613
  rmSync(dir, { recursive: true, force: true });
378
614
  });
379
615
 
616
+ test("buildLaunch: vision model → deepseek-v4-flash-vision-exp in patch", () => {
617
+ const { dir, bin } = makeFakeDsh("vision");
618
+ const promptFile = join(dir, "job.prompt");
619
+ writeFileSync(promptFile, "ping");
620
+ withEnv(
621
+ {
622
+ CURSOR_ROUTE_DSH_BIN: bin,
623
+ DEEPSEEK_API_KEY: "test-key",
624
+ CURSOR_ROUTE_DS_MODEL: undefined,
625
+ ANTHROPIC_MODEL: undefined,
626
+ },
627
+ () => {
628
+ deepseekAdapter.buildLaunch({
629
+ promptFile,
630
+ cwd: dir,
631
+ alwaysApprove: true,
632
+ model: "vision",
633
+ });
634
+ const patch = readFileSync(join(dir, "job.dsh-patch.yml"), "utf8");
635
+ expect(patch).toContain("deepseek-v4-flash-vision-exp");
636
+ },
637
+ );
638
+ rmSync(dir, { recursive: true, force: true });
639
+ });
640
+
380
641
  test("buildLaunch: pro model → deepseek-v4-pro in patch", () => {
381
642
  const { dir, bin } = makeFakeDsh("pro");
382
643
  const promptFile = join(dir, "job.prompt");
@@ -618,7 +879,7 @@ describe("health", () => {
618
879
  test("returns structured report", () => {
619
880
  const r = runHealth();
620
881
  expect(r.product).toBe("cursor-route");
621
- expect(r.version).toBe("0.1.10");
882
+ expect(r.version).toBe("0.1.13");
622
883
  expect(r.checks.length).toBeGreaterThan(3);
623
884
  expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
624
885
  expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
@@ -630,8 +891,8 @@ describe("health", () => {
630
891
  expect(r.checks.some((c) => c.name === "worker:deepseek")).toBe(true);
631
892
  });
632
893
 
633
- test("config version is 0.1.10", () => {
634
- expect(config.version).toBe("0.1.10");
894
+ test("config version is 0.1.13", () => {
895
+ expect(config.version).toBe("0.1.13");
635
896
  });
636
897
 
637
898
  test("OR-gate: ok can be true while worker:opencode is false", () => {
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  LANES,
11
11
  resolveDsModel,
12
12
  openCodeModel,
13
+ openRouterModel,
13
14
  type WorkerKind,
14
15
  type Lane,
15
16
  type DsModelAlias,
@@ -56,7 +57,7 @@ Usage:
56
57
  Start options:
57
58
  --worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
58
59
  --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 (default opencode/big-pickle) or provider/model
60
+ --model <flash|pro|vision|free|provider/model> claude-ds/deepseek: flash|pro|vision. opencode/openrouter: free (live catalog pick) or provider/model
60
61
  --dir <path> Working directory (default: cwd)
61
62
  --ask Disable always-approve for this job
62
63
  --dry-run Print launch command; do not start
@@ -69,16 +70,16 @@ Env:
69
70
  CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
70
71
  CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
71
72
  CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default; --model ignored)
72
- CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (or deepseek-v4-pro[1m]); overridden by --model
73
+ CURSOR_ROUTE_DS_MODEL Default mid model flash|pro|vision (or full ids / deepseek-v4-pro[1m]); overridden by --model
73
74
  CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
74
75
  CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
75
76
  CURSOR_ROUTE_DSH_BIN Override the dsh binary path (tests / power users)
76
77
  CURSOR_ROUTE_OPENCODE_BIN Override the opencode binary path (tests / power users)
77
78
  DEEPSEEK_API_KEY DeepSeek API key (required for --worker deepseek)
78
79
  OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
79
- CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
80
+ CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter pin (unset or free = live catalog pick at start; fetch-fail fallback openrouter/free)
80
81
  OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
81
- CURSOR_ROUTE_OPENCODE_MODEL OpenCode model (default: opencode/big-pickle); --model overrides
82
+ CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
82
83
  `);
83
84
  process.exit(exitCode);
84
85
  }
@@ -170,18 +171,26 @@ function asLane(v: unknown): Lane | undefined {
170
171
 
171
172
  function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | undefined {
172
173
  if (v === undefined || v === true) return undefined;
173
- if (typeof v !== "string") throw new Error(`Invalid --model; expected flash|pro`);
174
+ if (typeof v !== "string") throw new Error(`Invalid --model; expected flash|pro|vision`);
174
175
  return resolveDsModel(v);
175
176
  }
176
177
 
177
178
  function asOpenCodeModel(v: unknown): string | undefined {
178
179
  if (v === undefined || v === true) return undefined;
179
180
  if (typeof v !== "string") {
180
- throw new Error(`Invalid --model; expected provider/model (e.g. opencode/big-pickle) or free`);
181
+ throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
181
182
  }
182
183
  return openCodeModel(v);
183
184
  }
184
185
 
186
+ function asOpenRouterModel(v: unknown): string | undefined {
187
+ if (v === undefined || v === true) return undefined;
188
+ if (typeof v !== "string") {
189
+ throw new Error(`Invalid --model; expected provider/model or free (live OpenRouter catalog pick)`);
190
+ }
191
+ return openRouterModel(v);
192
+ }
193
+
185
194
  function refuseSecrets(text: string, context: string): void {
186
195
  if (looksLikeSecretMaterial(text)) {
187
196
  console.error(
@@ -264,8 +273,8 @@ async function main() {
264
273
 
265
274
  let model: DsModelAlias | undefined;
266
275
  let modelId: string | undefined;
267
- // --model: DeepSeek flash|pro for claude-ds/deepseek; provider/model (or free) for opencode;
268
- // ignore (do not validate) for grok/openrouter
276
+ // --model: DeepSeek flash|pro|vision for claude-ds/deepseek;
277
+ // provider/model (or free) for opencode/openrouter; ignore for grok
269
278
  if (f.model !== undefined && (resolvedWorker === "claude-ds" || resolvedWorker === "deepseek")) {
270
279
  try {
271
280
  const choice = asDsModelChoice(f.model);
@@ -284,6 +293,13 @@ async function main() {
284
293
  console.error((e as Error).message);
285
294
  process.exit(2);
286
295
  }
296
+ } else if (f.model !== undefined && resolvedWorker === "openrouter") {
297
+ try {
298
+ modelId = asOpenRouterModel(f.model);
299
+ } catch (e) {
300
+ console.error((e as Error).message);
301
+ process.exit(2);
302
+ }
287
303
  }
288
304
 
289
305
  let prompt = "";
package/src/config.ts CHANGED
@@ -11,7 +11,7 @@ export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek" | "ope
11
11
  export type Lane = "easy" | "mid" | "hard";
12
12
 
13
13
  /** Public CLI aliases for mid-lane DeepSeek models. */
14
- export type DsModelAlias = "flash" | "pro";
14
+ export type DsModelAlias = "flash" | "pro" | "vision";
15
15
 
16
16
  export interface DsModelChoice {
17
17
  alias: DsModelAlias;
@@ -21,13 +21,22 @@ export interface DsModelChoice {
21
21
 
22
22
  export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek", "opencode"];
23
23
  export const LANES: Lane[] = ["easy", "mid", "hard"];
24
- export const DS_MODELS: DsModelAlias[] = ["flash", "pro"];
24
+ export const DS_MODELS: DsModelAlias[] = ["flash", "pro", "vision"];
25
25
 
26
26
  export const DS_MODEL_IDS: Record<DsModelAlias, string> = {
27
27
  flash: "deepseek-v4-flash",
28
28
  pro: "deepseek-v4-pro",
29
+ vision: "deepseek-v4-flash-vision-exp",
29
30
  };
30
31
 
32
+ /** Prompt looks like a screenshot / image / ui mock — vision auto-pick. */
33
+ const VISION_PROMPT_RE =
34
+ /screenshot|\bimage\b|\.png|\.jpe?g|\.webp|ui mock|multimodal|\bvision\b/i;
35
+
36
+ export function promptLooksLikeVision(prompt: string): boolean {
37
+ return VISION_PROMPT_RE.test(prompt);
38
+ }
39
+
31
40
  /**
32
41
  * Resolve --model / env to alias + concrete model id.
33
42
  * Preserves `deepseek-v4-pro[1m]` (does not silently strip the SKU).
@@ -44,10 +53,13 @@ export function resolveDsModel(raw?: string | null): DsModelChoice {
44
53
  if (v === "pro" || v === "deepseek-v4-pro") {
45
54
  return { alias: "pro", id: DS_MODEL_IDS.pro };
46
55
  }
56
+ if (v === "vision" || v === "deepseek-v4-flash-vision-exp") {
57
+ return { alias: "vision", id: DS_MODEL_IDS.vision };
58
+ }
47
59
  if (v === "deepseek-v4-pro[1m]") {
48
60
  return { alias: "pro", id: "deepseek-v4-pro[1m]" };
49
61
  }
50
- throw new Error(`Invalid --model ${raw}; expected flash|pro`);
62
+ throw new Error(`Invalid DeepSeek model ${raw}; expected flash|pro|vision`);
51
63
  }
52
64
 
53
65
  /** Alias-only helper (tests / callers that do not need the concrete id). */
@@ -64,46 +76,24 @@ export function defaultDsModelFromEnv(): DsModelChoice {
64
76
  return resolveDsModel(raw);
65
77
  }
66
78
 
67
- /** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
68
- export function openRouterModel(): string {
69
- return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
70
- }
71
-
72
79
  /** OpenRouter API base URL (env OPENROUTER_BASE_URL). */
73
80
  export function openRouterBaseUrl(): string {
74
81
  return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
75
82
  }
76
83
 
77
- /**
78
- * Default OpenCode model: OpenCode Zen Big Pickle (free, limited-time).
79
- * Override: CURSOR_ROUTE_OPENCODE_MODEL or `--model provider/model`.
80
- * Alias `free` resolves to this default (or the env override).
81
- */
82
- export const OPENCODE_DEFAULT_MODEL = "opencode/big-pickle";
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
- }
84
+ export {
85
+ OPENCODE_DEFAULT_MODEL,
86
+ assertOpenCodeModel,
87
+ openCodeModel,
88
+ cachedZenFreePick,
89
+ } from "./zen-free.ts";
93
90
 
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
- }
91
+ export {
92
+ OPENROUTER_FALLBACK_MODEL,
93
+ assertOpenRouterModel,
94
+ cachedOrFreePick,
95
+ openRouterModel,
96
+ } from "./or-free.ts";
107
97
 
108
98
  function maxConcurrentJobsFromEnv(): number {
109
99
  const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
@@ -121,7 +111,7 @@ function maxConcurrentJobsFromEnv(): number {
121
111
  */
122
112
  export const config = {
123
113
  product: "cursor-route",
124
- version: "0.1.10",
114
+ version: "0.1.13",
125
115
  get jobsDir(): string {
126
116
  return defaultJobsDir();
127
117
  },
package/src/jobs.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  defaultDsModelFromEnv,
19
19
  DS_MODEL_IDS,
20
20
  openCodeModel,
21
+ openRouterModel,
22
+ promptLooksLikeVision,
21
23
  type Lane,
22
24
  type WorkerKind,
23
25
  type DsModelAlias,
@@ -38,7 +40,7 @@ export interface Job {
38
40
  status: JobStatus;
39
41
  worker: WorkerKind;
40
42
  lane?: Lane;
41
- /** Worker model: flash|pro for claude-ds/deepseek; provider/model for opencode. */
43
+ /** Worker model: flash|pro|vision for claude-ds/deepseek; provider/model for opencode/openrouter. */
42
44
  model?: string;
43
45
  prompt: string;
44
46
  cwd: string;
@@ -194,13 +196,16 @@ function terminatePid(pid: number): boolean {
194
196
  if (!pidAlive(pid)) return true;
195
197
  sleepMs(100);
196
198
  }
197
- trySignal("SIGKILL", true);
198
- trySignal("SIGKILL", false);
199
+ const gKill = trySignal("SIGKILL", true);
200
+ const pKill = trySignal("SIGKILL", false);
199
201
  for (let i = 0; i < 10; i++) {
200
202
  if (!pidAlive(pid)) return true;
201
203
  sleepMs(100);
202
204
  }
203
- return !pidAlive(pid);
205
+ if (!pidAlive(pid)) return true;
206
+ // Sandboxed `ps` (EPERM) treats unreaped zombies as alive. If SIGKILL was
207
+ // sent, killJob should succeed — refreshStatus still fail-safes to alive.
208
+ return gKill || pKill;
204
209
  }
205
210
 
206
211
  /** Refresh running jobs without inventing success from "session gone". */
@@ -281,9 +286,9 @@ export interface StartOptions {
281
286
  prompt: string;
282
287
  worker?: WorkerKind;
283
288
  lane?: Lane;
284
- /** Mid-lane DeepSeek: flash (default) | pro (claude-ds + deepseek). Ignored by grok/openrouter. */
289
+ /** Mid-lane DeepSeek: flash (default) | vision | pro (claude-ds + deepseek). Ignored by grok. */
285
290
  model?: DsModelAlias;
286
- /** Concrete DeepSeek id (preserves pro[1m]) or OpenCode provider/model. Derived from --model / env when unset. */
291
+ /** Concrete DeepSeek id (preserves pro[1m]) or OpenCode/OpenRouter provider/model. Derived from --model / env when unset. */
287
292
  modelId?: string;
288
293
  cwd?: string;
289
294
  alwaysApprove?: boolean;
@@ -341,18 +346,32 @@ export function startJob(opts: StartOptions): {
341
346
  model = opts.model;
342
347
  modelId = opts.modelId ?? DS_MODEL_IDS[opts.model];
343
348
  } else {
344
- try {
345
- const choice = defaultDsModelFromEnv();
346
- dsAlias = choice.alias;
347
- model = choice.alias;
348
- modelId = opts.modelId ?? choice.id;
349
- } catch (e) {
349
+ const envRaw = (process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL || "").trim();
350
+ if (envRaw) {
350
351
  try {
351
- unlinkSync(paths.prompt);
352
+ const choice = defaultDsModelFromEnv();
353
+ dsAlias = choice.alias;
354
+ model = choice.alias;
355
+ modelId = opts.modelId ?? choice.id;
352
356
  } catch {
353
- /* ignore */
357
+ try {
358
+ unlinkSync(paths.prompt);
359
+ } catch {
360
+ /* ignore */
361
+ }
362
+ return {
363
+ ok: false,
364
+ error: `Invalid CURSOR_ROUTE_DS_MODEL/ANTHROPIC_MODEL value ${envRaw}; expected flash|pro|vision`,
365
+ };
354
366
  }
355
- return { ok: false, error: (e as Error).message };
367
+ } else if (promptLooksLikeVision(opts.prompt)) {
368
+ dsAlias = "vision";
369
+ model = "vision";
370
+ modelId = opts.modelId ?? DS_MODEL_IDS.vision;
371
+ } else {
372
+ dsAlias = "flash";
373
+ model = "flash";
374
+ modelId = opts.modelId ?? DS_MODEL_IDS.flash;
356
375
  }
357
376
  }
358
377
  } else if (worker === "opencode") {
@@ -368,6 +387,19 @@ export function startJob(opts: StartOptions): {
368
387
  }
369
388
  return { ok: false, error: (e as Error).message };
370
389
  }
390
+ } else if (worker === "openrouter") {
391
+ try {
392
+ const orModel = openRouterModel(opts.modelId);
393
+ model = orModel;
394
+ modelId = orModel;
395
+ } catch (e) {
396
+ try {
397
+ unlinkSync(paths.prompt);
398
+ } catch {
399
+ /* ignore */
400
+ }
401
+ return { ok: false, error: (e as Error).message };
402
+ }
371
403
  }
372
404
 
373
405
  let plan;
@@ -24,7 +24,7 @@ function parseArgs(argv: string[]): { promptFile?: string } {
24
24
  if (a === "-h" || a === "--help") {
25
25
  console.log(
26
26
  "usage: cursor-route/openrouter-run --prompt-file <path>\n" +
27
- "env: OPENROUTER_API_KEY (required), CURSOR_ROUTE_OPENROUTER_MODEL, OPENROUTER_BASE_URL",
27
+ "env: OPENROUTER_API_KEY (required), CURSOR_ROUTE_OPENROUTER_MODEL (unset/free = live pick), OPENROUTER_BASE_URL",
28
28
  );
29
29
  process.exit(0);
30
30
  }