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/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +3 -2
- package/README.md +44 -28
- package/SECURITY.md +1 -1
- package/dist/adapters/claude-ds.js +1 -1
- package/dist/adapters/opencode.js +8 -13
- package/dist/adapters/openrouter.js +13 -7
- package/dist/cli.js +26 -9
- package/dist/config.js +14 -34
- package/dist/jobs.js +50 -14
- package/dist/openrouter-run.js +1 -1
- package/dist/or-free.js +216 -0
- package/dist/zen-free.js +237 -0
- package/docs/briefs/WORKING.md +17 -9
- package/docs/demo-notes.md +3 -3
- package/docs/fixtures/generate-hero-demo.sh +3 -0
- package/docs/fixtures/hero-demo.log +5 -3
- package/llms.txt +10 -8
- package/package.json +1 -1
- package/skills/route-orch/SKILL.md +13 -8
- package/src/adapters/claude-ds.ts +1 -1
- package/src/adapters/opencode.test.ts +84 -25
- package/src/adapters/opencode.ts +8 -12
- package/src/adapters/openrouter.test.ts +32 -3
- package/src/adapters/openrouter.ts +17 -7
- package/src/adapters/types.ts +2 -2
- package/src/cli.test.ts +266 -5
- package/src/cli.ts +24 -8
- package/src/config.ts +28 -38
- package/src/jobs.ts +47 -15
- package/src/openrouter-run.ts +1 -1
- package/src/or-free.test.ts +222 -0
- package/src/or-free.ts +243 -0
- package/src/zen-free.test.ts +137 -0
- package/src/zen-free.ts +258 -0
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.
|
|
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.
|
|
634
|
-
expect(config.version).toBe("0.1.
|
|
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 (
|
|
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
|
|
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 (
|
|
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/
|
|
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;
|
|
268
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
84
|
+
export {
|
|
85
|
+
OPENCODE_DEFAULT_MODEL,
|
|
86
|
+
assertOpenCodeModel,
|
|
87
|
+
openCodeModel,
|
|
88
|
+
cachedZenFreePick,
|
|
89
|
+
} from "./zen-free.ts";
|
|
93
90
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
352
|
+
const choice = defaultDsModelFromEnv();
|
|
353
|
+
dsAlias = choice.alias;
|
|
354
|
+
model = choice.alias;
|
|
355
|
+
modelId = opts.modelId ?? choice.id;
|
|
352
356
|
} catch {
|
|
353
|
-
|
|
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
|
-
|
|
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;
|
package/src/openrouter-run.ts
CHANGED
|
@@ -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
|
}
|