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.
@@ -0,0 +1,222 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ assertOpenRouterModel,
7
+ isOrFreeModel,
8
+ openRouterModel,
9
+ OPENROUTER_FALLBACK_MODEL,
10
+ orFreeBoost,
11
+ pickOrFreeModel,
12
+ rankOrFreeModels,
13
+ type OrModel,
14
+ } from "./or-free.ts";
15
+ import { openRouterAdapter } from "./adapters/openrouter.ts";
16
+
17
+ const CATALOG: OrModel[] = [
18
+ { id: "acme/generic:free", name: "Generic Free", context_length: 8_000 },
19
+ { id: "qwen/qwen-coder:free", name: "Qwen Coder", context_length: 128_000 },
20
+ { id: "acme/whisper:free", name: "Whisper" },
21
+ {
22
+ id: "anthropic/claude-sonnet",
23
+ name: "Sonnet",
24
+ pricing: { prompt: 3, completion: 15 },
25
+ },
26
+ {
27
+ id: "acme/zero-price",
28
+ name: "Zero Price Text",
29
+ context_length: 4_000,
30
+ pricing: { prompt: "0", completion: "0" },
31
+ },
32
+ {
33
+ id: "acme/image-only",
34
+ name: "Image Only",
35
+ architecture: { modality: "image->image" },
36
+ pricing: { prompt: 0, completion: 0 },
37
+ },
38
+ ];
39
+
40
+ describe("openrouter free catalog", () => {
41
+ test("tags :free or zero price as free; skips paid, whisper, image-only", () => {
42
+ expect(isOrFreeModel({ id: "qwen/qwen-coder:free" })).toBe(true);
43
+ expect(
44
+ isOrFreeModel({
45
+ id: "acme/zero-price",
46
+ pricing: { prompt: 0, completion: 0 },
47
+ }),
48
+ ).toBe(true);
49
+ expect(
50
+ isOrFreeModel({
51
+ id: "anthropic/claude-sonnet",
52
+ pricing: { prompt: 3, completion: 15 },
53
+ }),
54
+ ).toBe(false);
55
+ expect(isOrFreeModel({ id: "acme/whisper:free" })).toBe(false);
56
+ expect(
57
+ isOrFreeModel({
58
+ id: "acme/image-only",
59
+ architecture: { modality: "image->image" },
60
+ pricing: { prompt: 0, completion: 0 },
61
+ }),
62
+ ).toBe(false);
63
+ });
64
+
65
+ test("boost: coder/qwen outranks generic free; no hardcoded winner id", () => {
66
+ expect(orFreeBoost("qwen/qwen-coder:free")).toBe(20);
67
+ expect(orFreeBoost("acme/generic:free")).toBe(10);
68
+ expect(orFreeBoost("meta-llama/llama-3.3-70b-instruct:free")).toBe(20);
69
+ });
70
+
71
+ test("rank prefers a :free coder with larger context over a generic :free", () => {
72
+ const ranked = rankOrFreeModels(CATALOG);
73
+ expect(ranked[0]?.id).toBe("qwen/qwen-coder:free");
74
+ expect(ranked.map((r) => r.id)).not.toContain("acme/whisper:free");
75
+ expect(ranked.map((r) => r.id)).not.toContain("anthropic/claude-sonnet");
76
+ expect(ranked.map((r) => r.id)).not.toContain("acme/image-only");
77
+ });
78
+
79
+ test("same boost: larger context_length wins, then id ascending", () => {
80
+ const ranked = rankOrFreeModels([
81
+ { id: "qwen/small-coder:free", context_length: 8_000 },
82
+ { id: "qwen/big-coder:free", context_length: 128_000 },
83
+ ]);
84
+ expect(ranked[0]?.id).toBe("qwen/big-coder:free");
85
+ expect(ranked[1]?.id).toBe("qwen/small-coder:free");
86
+ });
87
+
88
+ test("pickOrFreeModel uses the ranked catalog winner", () => {
89
+ expect(pickOrFreeModel({ catalog: CATALOG })).toBe("qwen/qwen-coder:free");
90
+ });
91
+
92
+ test("offline / empty catalog falls back to the OpenRouter router", () => {
93
+ expect(pickOrFreeModel({ catalog: [] })).toBe(OPENROUTER_FALLBACK_MODEL);
94
+ expect(OPENROUTER_FALLBACK_MODEL).toBe("openrouter/free");
95
+ });
96
+
97
+ test("assertOpenRouterModel accepts provider/model and :free", () => {
98
+ expect(assertOpenRouterModel("qwen/qwen-coder:free")).toBe("qwen/qwen-coder:free");
99
+ expect(assertOpenRouterModel("openrouter/free")).toBe("openrouter/free");
100
+ expect(() => assertOpenRouterModel("not-a-model")).toThrow(/provider\/model/);
101
+ });
102
+ });
103
+
104
+ describe("openRouterModel live pick", () => {
105
+ const isolate = (fn: () => void) => {
106
+ const dir = join(tmpdir(), `cr-or-${process.pid}-${Math.random().toString(16).slice(2)}`);
107
+ mkdirSync(dir, { recursive: true });
108
+ const prev = {
109
+ model: process.env.CURSOR_ROUTE_OPENROUTER_MODEL,
110
+ offline: process.env.CURSOR_ROUTE_OR_OFFLINE,
111
+ json: process.env.CURSOR_ROUTE_OR_CATALOG_JSON,
112
+ cache: process.env.CURSOR_ROUTE_OR_CACHE_PATH,
113
+ refresh: process.env.CURSOR_ROUTE_OR_REFRESH,
114
+ };
115
+ delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
116
+ process.env.CURSOR_ROUTE_OR_CACHE_PATH = join(dir, "cache.json");
117
+ process.env.CURSOR_ROUTE_OR_REFRESH = "1";
118
+ try {
119
+ fn();
120
+ } finally {
121
+ for (const [k, v] of Object.entries(prev)) {
122
+ const envKey = {
123
+ model: "CURSOR_ROUTE_OPENROUTER_MODEL",
124
+ offline: "CURSOR_ROUTE_OR_OFFLINE",
125
+ json: "CURSOR_ROUTE_OR_CATALOG_JSON",
126
+ cache: "CURSOR_ROUTE_OR_CACHE_PATH",
127
+ refresh: "CURSOR_ROUTE_OR_REFRESH",
128
+ }[k]!;
129
+ if (v === undefined) delete process.env[envKey];
130
+ else process.env[envKey] = v;
131
+ }
132
+ rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ };
135
+
136
+ test("free/empty + offline → openrouter/free fallback", () => {
137
+ isolate(() => {
138
+ process.env.CURSOR_ROUTE_OR_OFFLINE = "1";
139
+ expect(openRouterModel()).toBe("openrouter/free");
140
+ expect(openRouterModel("free")).toBe("openrouter/free");
141
+ });
142
+ });
143
+
144
+ test("free + injected catalog → ranked coder", () => {
145
+ isolate(() => {
146
+ process.env.CURSOR_ROUTE_OR_CATALOG_JSON = JSON.stringify({ data: CATALOG });
147
+ expect(openRouterModel("free")).toBe("qwen/qwen-coder:free");
148
+ });
149
+ });
150
+
151
+ test("CURSOR_ROUTE_OPENROUTER_MODEL pin wins over catalog", () => {
152
+ isolate(() => {
153
+ process.env.CURSOR_ROUTE_OPENROUTER_MODEL = "acme/pinned:free";
154
+ process.env.CURSOR_ROUTE_OR_CATALOG_JSON = JSON.stringify({ data: CATALOG });
155
+ expect(openRouterModel()).toBe("acme/pinned:free");
156
+ expect(openRouterModel("free")).toBe("acme/pinned:free");
157
+ });
158
+ });
159
+
160
+ test("explicit provider/model pin is used as-is", () => {
161
+ isolate(() => {
162
+ process.env.CURSOR_ROUTE_OR_CATALOG_JSON = JSON.stringify({ data: CATALOG });
163
+ expect(openRouterModel("meta-llama/llama-3.3-70b-instruct:free")).toBe(
164
+ "meta-llama/llama-3.3-70b-instruct:free",
165
+ );
166
+ });
167
+ });
168
+
169
+ test("offline fallback is not written to cache", () => {
170
+ isolate(() => {
171
+ delete process.env.CURSOR_ROUTE_OR_REFRESH;
172
+ process.env.CURSOR_ROUTE_OR_OFFLINE = "1";
173
+ const cache = process.env.CURSOR_ROUTE_OR_CACHE_PATH!;
174
+ expect(openRouterModel()).toBe("openrouter/free");
175
+ expect(existsSync(cache)).toBe(false);
176
+ });
177
+ });
178
+ });
179
+
180
+ describe("openrouter health stays offline", () => {
181
+ test("health with key + offline does not throw; mentions live pick, not a locked third-party default", () => {
182
+ const dir = join(tmpdir(), `cr-or-health-${process.pid}`);
183
+ mkdirSync(dir, { recursive: true });
184
+ const prev = {
185
+ key: process.env.OPENROUTER_API_KEY,
186
+ model: process.env.CURSOR_ROUTE_OPENROUTER_MODEL,
187
+ offline: process.env.CURSOR_ROUTE_OR_OFFLINE,
188
+ json: process.env.CURSOR_ROUTE_OR_CATALOG_JSON,
189
+ cache: process.env.CURSOR_ROUTE_OR_CACHE_PATH,
190
+ refresh: process.env.CURSOR_ROUTE_OR_REFRESH,
191
+ };
192
+ process.env.OPENROUTER_API_KEY = "sk-or-v1-local-test-value-000000000000";
193
+ delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
194
+ process.env.CURSOR_ROUTE_OR_OFFLINE = "1";
195
+ delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
196
+ process.env.CURSOR_ROUTE_OR_CACHE_PATH = join(dir, "missing-cache.json");
197
+ delete process.env.CURSOR_ROUTE_OR_REFRESH;
198
+ try {
199
+ const h = openRouterAdapter.health();
200
+ expect(h.ok).toBe(true);
201
+ expect(h.detail.toLowerCase()).toMatch(/live pick/);
202
+ expect(h.detail).toMatch(/fallback/);
203
+ expect(h.detail).not.toMatch(/now openrouter\/free/);
204
+ expect(h.detail).not.toMatch(/qwen\//);
205
+ expect(h.detail).not.toMatch(/defaults to openrouter\/free/);
206
+ } finally {
207
+ if (prev.key === undefined) delete process.env.OPENROUTER_API_KEY;
208
+ else process.env.OPENROUTER_API_KEY = prev.key;
209
+ if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
210
+ else process.env.CURSOR_ROUTE_OPENROUTER_MODEL = prev.model;
211
+ if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_OR_OFFLINE;
212
+ else process.env.CURSOR_ROUTE_OR_OFFLINE = prev.offline;
213
+ if (prev.json === undefined) delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
214
+ else process.env.CURSOR_ROUTE_OR_CATALOG_JSON = prev.json;
215
+ if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_OR_CACHE_PATH;
216
+ else process.env.CURSOR_ROUTE_OR_CACHE_PATH = prev.cache;
217
+ if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_OR_REFRESH;
218
+ else process.env.CURSOR_ROUTE_OR_REFRESH = prev.refresh;
219
+ rmSync(dir, { recursive: true, force: true });
220
+ }
221
+ });
222
+ });
package/src/or-free.ts ADDED
@@ -0,0 +1,243 @@
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 OpenRouter free-model pick — same shape as zen-free.ts.
8
+ * Probe GET {OPENROUTER_BASE_URL}/models, keep $0 text models, rank, cache
9
+ * ~15 min. Do not hardcode a third-party model id as the happy-path default.
10
+ * `openrouter/free` is the OpenRouter **router** fallback only.
11
+ */
12
+
13
+ export const OPENROUTER_MODELS_URL_DEFAULT = "https://openrouter.ai/api/v1/models";
14
+
15
+ /** Fetch-fail / empty-rank fallback — OpenRouter live router, not a locked model. */
16
+ export const OPENROUTER_FALLBACK_MODEL = "openrouter/free";
17
+
18
+ export interface OrModel {
19
+ id?: string;
20
+ name?: string;
21
+ context_length?: number;
22
+ pricing?: { prompt?: number | string; completion?: number | string };
23
+ architecture?: { modality?: string };
24
+ }
25
+
26
+ export interface OrFreePick {
27
+ id: string;
28
+ name: string;
29
+ context_length: number;
30
+ boost: number;
31
+ picked_at: string;
32
+ candidates: number;
33
+ source: "ranked-free" | "fallback";
34
+ }
35
+
36
+ const EXCLUDE_RE =
37
+ /lyria|whisper|tts|embed|embedding|image|vision-only|audio|diffusion|flux|stable-diffusion|moderation/i;
38
+ const BOOST_RE =
39
+ /coder|instruct|chat|nemotron|qwen|llama|gemma|gpt-oss|kimi|glm|deepseek/i;
40
+
41
+ function orModelsUrl(): string {
42
+ const base = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1")
43
+ .trim()
44
+ .replace(/\/+$/, "");
45
+ return `${base}/models`;
46
+ }
47
+
48
+ function cacheMinutes(): number {
49
+ const n = Number(process.env.CURSOR_ROUTE_OR_CACHE_MINUTES);
50
+ return Number.isFinite(n) && n > 0 ? n : 15;
51
+ }
52
+
53
+ function cachePath(): string {
54
+ return process.env.CURSOR_ROUTE_OR_CACHE_PATH?.trim() ||
55
+ join(tmpdir(), "cursor-route-or-best-free.json");
56
+ }
57
+
58
+ export function assertOpenRouterModel(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 OpenRouter model ${id}; expected provider/model (e.g. qwen/qwen3-coder:free) or free`,
62
+ );
63
+ }
64
+ return id;
65
+ }
66
+
67
+ function num(v: unknown): number {
68
+ if (typeof v === "number" && Number.isFinite(v)) return v;
69
+ if (typeof v === "string" && v.trim()) {
70
+ const n = Number(v);
71
+ if (Number.isFinite(n)) return n;
72
+ }
73
+ return NaN;
74
+ }
75
+
76
+ export function isOrFreeModel(m: OrModel): boolean {
77
+ const id = (m.id || "").trim();
78
+ if (!id) return false;
79
+ if (EXCLUDE_RE.test(id) || EXCLUDE_RE.test(m.name || "")) return false;
80
+ const modality = m.architecture?.modality;
81
+ if (typeof modality === "string" && modality.trim() && !/text/i.test(modality)) {
82
+ return false;
83
+ }
84
+ const prompt = num(m.pricing?.prompt);
85
+ const completion = num(m.pricing?.completion);
86
+ const pricedFree = prompt === 0 && completion === 0;
87
+ const tagged = id.endsWith(":free");
88
+ return tagged || pricedFree;
89
+ }
90
+
91
+ /** Higher boost wins. Coding/chat families beat generic free. No hardcoded id. */
92
+ export function orFreeBoost(id: string): number {
93
+ return BOOST_RE.test(id) ? 20 : 10;
94
+ }
95
+
96
+ export function rankOrFreeModels(models: OrModel[]): Array<{
97
+ id: string;
98
+ name: string;
99
+ context_length: number;
100
+ boost: number;
101
+ }> {
102
+ const out: Array<{ id: string; name: string; context_length: number; boost: number }> = [];
103
+ for (const m of models) {
104
+ if (!isOrFreeModel(m)) continue;
105
+ const raw = (m.id || "").trim();
106
+ let id: string;
107
+ try {
108
+ id = assertOpenRouterModel(raw);
109
+ } catch {
110
+ continue;
111
+ }
112
+ const ctx = Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : 0;
113
+ out.push({
114
+ id,
115
+ name: (m.name || raw).trim(),
116
+ context_length: ctx,
117
+ boost: orFreeBoost(id),
118
+ });
119
+ }
120
+ out.sort((a, b) => {
121
+ if (b.boost !== a.boost) return b.boost - a.boost;
122
+ if (b.context_length !== a.context_length) return b.context_length - a.context_length;
123
+ return a.id.localeCompare(b.id);
124
+ });
125
+ return out;
126
+ }
127
+
128
+ function readCache(): OrFreePick | null {
129
+ const p = cachePath();
130
+ if (!existsSync(p)) return null;
131
+ try {
132
+ const j = JSON.parse(readFileSync(p, "utf8")) as OrFreePick;
133
+ if (!j?.id) return null;
134
+ const ageMs = Date.now() - Date.parse(j.picked_at);
135
+ if (!Number.isFinite(ageMs) || ageMs < 0) return null;
136
+ if (ageMs > cacheMinutes() * 60_000) return null;
137
+ assertOpenRouterModel(j.id);
138
+ return j;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function writeCache(pick: OrFreePick): void {
145
+ try {
146
+ writeFileSync(cachePath(), JSON.stringify(pick), { mode: 0o600 });
147
+ } catch {
148
+ /* cache is optional */
149
+ }
150
+ }
151
+
152
+ function httpGetSync(url: string, timeoutMs = 4000): string | null {
153
+ const sec = Math.max(1, Math.ceil(timeoutMs / 1000));
154
+ // Public GET /models — no Authorization header (key would land in `ps` argv).
155
+ const curl = spawnSync(
156
+ "curl",
157
+ ["-fsS", "--max-time", String(sec), "-H", "Accept: application/json", url],
158
+ {
159
+ encoding: "utf8",
160
+ timeout: timeoutMs + 500,
161
+ env: { ...process.env },
162
+ },
163
+ );
164
+ if (curl.status === 0 && curl.stdout?.trim()) return curl.stdout;
165
+ return null;
166
+ }
167
+
168
+ function parseCatalog(raw: string): OrModel[] {
169
+ const j = JSON.parse(raw) as { data?: OrModel[] } | OrModel[];
170
+ if (Array.isArray(j)) return j;
171
+ if (Array.isArray(j.data)) return j.data;
172
+ return [];
173
+ }
174
+
175
+ export function loadOrCatalog(): OrModel[] {
176
+ const inline = process.env.CURSOR_ROUTE_OR_CATALOG_JSON?.trim();
177
+ if (inline) return parseCatalog(inline);
178
+ if (process.env.CURSOR_ROUTE_OR_OFFLINE === "1") return [];
179
+ const body = httpGetSync(orModelsUrl());
180
+ if (!body) return [];
181
+ try {
182
+ return parseCatalog(body);
183
+ } catch {
184
+ return [];
185
+ }
186
+ }
187
+
188
+ /** Last live pick if the cache is still fresh — health-safe (no network). */
189
+ export function cachedOrFreePick(): string | null {
190
+ try {
191
+ return readCache()?.id ?? null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Best live OpenRouter free text model, or OPENROUTER_FALLBACK_MODEL if the
199
+ * catalog is empty/offline. `CURSOR_ROUTE_OR_REFRESH=1` skips cache.
200
+ * Tests: `CURSOR_ROUTE_OR_OFFLINE=1` or `CURSOR_ROUTE_OR_CATALOG_JSON`.
201
+ */
202
+ export function pickOrFreeModel(opts?: { catalog?: OrModel[]; refresh?: boolean }): string {
203
+ const refresh =
204
+ opts?.refresh ||
205
+ process.env.CURSOR_ROUTE_OR_REFRESH === "1";
206
+ if (!refresh && opts?.catalog === undefined) {
207
+ const cached = readCache();
208
+ if (cached) return cached.id;
209
+ }
210
+ const models = opts?.catalog ?? loadOrCatalog();
211
+ const ranked = rankOrFreeModels(models);
212
+ const winner = ranked[0];
213
+ if (!winner) {
214
+ // Do not cache fallback — a 15-min stale fallback would masquerade as a
215
+ // live pick in health (`now openrouter/free`). Retry the catalog next call.
216
+ return OPENROUTER_FALLBACK_MODEL;
217
+ }
218
+ const pick: OrFreePick = {
219
+ id: winner.id,
220
+ name: winner.name,
221
+ context_length: winner.context_length,
222
+ boost: winner.boost,
223
+ picked_at: new Date().toISOString(),
224
+ candidates: ranked.length,
225
+ source: "ranked-free",
226
+ };
227
+ if (opts?.catalog === undefined) writeCache(pick);
228
+ return pick.id;
229
+ }
230
+
231
+ /**
232
+ * Resolve OpenRouter `--model` / env to a concrete `provider/model` id.
233
+ * Empty or `free` → CURSOR_ROUTE_OPENROUTER_MODEL pin, else live catalog pick.
234
+ */
235
+ export function openRouterModel(raw?: string | null): string {
236
+ const v = (raw ?? "").trim();
237
+ if (!v || v.toLowerCase() === "free") {
238
+ const env = (process.env.CURSOR_ROUTE_OPENROUTER_MODEL ?? "").trim();
239
+ if (env && env.toLowerCase() !== "free") return assertOpenRouterModel(env);
240
+ return pickOrFreeModel();
241
+ }
242
+ return assertOpenRouterModel(v);
243
+ }
@@ -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
+ });