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/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +42 -4
- package/SECURITY.md +6 -5
- package/SUPPORT.md +1 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/opencode.js +78 -0
- package/dist/cli.js +27 -8
- package/dist/config.js +4 -3
- package/dist/health.js +1 -1
- package/dist/jobs.js +21 -2
- package/dist/zen-free.js +237 -0
- package/docs/briefs/WORKING.md +11 -4
- package/llms.txt +8 -4
- package/package.json +3 -2
- package/skills/route-orch/SKILL.md +22 -7
- package/src/adapters/index.ts +2 -0
- package/src/adapters/opencode.test.ts +297 -0
- package/src/adapters/opencode.ts +85 -0
- package/src/adapters/types.ts +1 -1
- package/src/cli.test.ts +28 -3
- package/src/cli.ts +25 -7
- package/src/config.ts +14 -6
- package/src/health.ts +1 -1
- package/src/jobs.ts +23 -6
- package/src/zen-free.test.ts +137 -0
- package/src/zen-free.ts +258 -0
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
|
-
/**
|
|
41
|
-
model?:
|
|
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:
|
|
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:
|
|
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
|
+
});
|
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
|
+
}
|