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/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
|
+
}
|