Skip to content

Commit 76d6f4b

Browse files
committed
fix(variants): drop redundant thinking variant when effort enum present
Claude models via Cursor expose both a boolean thinking param and an effort enum. buildModelVariants emitted a variant for each, surfacing a stray thinking entry alongside low/medium/high/xhigh/max. Standard opencode providers (models.dev reasoning_options.effort) show only the five effort levels, so the boolean variant broke parity. Add a hasEffortEnum pre-pass; when true, skip the boolean reasoning variant. Effort alone enables reasoning (no thinking:true baked in). Boolean-only reasoning models still surface their single param-named variant. Order-independent. fast toggle and defaultModelParams untouched. Tests: updated dual-param expectation, added order-independence, production-shape+fast composition, zero-value-enum guard, and pinned current mixed-shape param behavior.
1 parent 563548d commit 76d6f4b

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

src/model-variants.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ export function buildModelVariants(item: ModelListItem): Record<string, CursorVa
5858
// reasoning variant so picking a reasoning level never re-enables fast.
5959
const defaults = defaultModelParams(item);
6060

61+
// Pre-pass: does any reasoning param expose a non-boolean effort enum (e.g.
62+
// ["low","medium","high","xhigh","max"])? When it does, a coexisting boolean
63+
// reasoning toggle (Cursor's `thinking=["false","true"]` on claude-* models)
64+
// is redundant — selecting any effort level already enables reasoning — and
65+
// surfacing it would add a stray `thinking` variant the standard opencode
66+
// providers don't show. Suppress the boolean variant for parity. Order-
67+
// independent: the enum may be declared before or after the boolean.
68+
const hasEffortEnum = (item.parameters ?? []).some(
69+
(p) => REASONING_PARAM.test(p.id) && !isBooleanParam(paramValues(p)) && paramValues(p).length > 0,
70+
);
71+
6172
for (const param of item.parameters ?? []) {
6273
const values = paramValues(param);
6374
if (values.length === 0) continue;
@@ -68,8 +79,9 @@ export function buildModelVariants(item: ModelListItem): Record<string, CursorVa
6879
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
6980
// variant names are meaningless in the picker — surface a single
7081
// variant named after the param that switches it on. "Off" is the
71-
// model's default (no variant selected).
72-
if (values.includes("true")) {
82+
// model's default (no variant selected). Skipped entirely when an
83+
// effort enum coexists (see hasEffortEnum above).
84+
if (!hasEffortEnum && values.includes("true")) {
7385
out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } };
7486
}
7587
continue;

test/model-variants.test.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,97 @@ describe("buildModelVariants", () => {
2727
});
2828
});
2929

30-
it("combines boolean thinking with enum effort (claude catalog shape)", () => {
30+
it("drops the boolean thinking variant when an effort enum is present (claude catalog shape)", () => {
31+
// Cursor's claude-* catalog exposes BOTH a boolean `thinking` toggle and an
32+
// effort enum. Selecting any effort level already enables reasoning, so the
33+
// standalone `thinking` variant is redundant — and surfacing it would add a
34+
// stray entry the standard opencode providers (effort-only) never show.
3135
const variants = buildModelVariants(
3236
model([
3337
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
3438
{ id: "effort", values: [{ value: "low" }, { value: "max" }] },
3539
]),
3640
);
3741
expect(variants).toEqual({
38-
thinking: { params: { thinking: "true" } },
3942
low: { params: { effort: "low" } },
4043
max: { params: { effort: "max" } },
4144
});
4245
});
4346

47+
it("suppresses the boolean thinking variant regardless of param order", () => {
48+
// Order-independence guard for the hasEffortEnum pre-pass: the effort enum
49+
// declared AFTER the boolean must still suppress it, and vice versa.
50+
const enumFirst = buildModelVariants(
51+
model([
52+
{ id: "effort", values: [{ value: "low" }, { value: "max" }] },
53+
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
54+
]),
55+
);
56+
expect(enumFirst).toEqual({
57+
low: { params: { effort: "low" } },
58+
max: { params: { effort: "max" } },
59+
});
60+
});
61+
62+
it("composes suppression with fast defaults (production claude-via-Cursor shape)", () => {
63+
// The real catalog model: boolean `thinking` + effort enum + `fast`. The
64+
// `thinking` variant is suppressed, each effort variant bakes `fast` OFF
65+
// (defaultModelParams), and a standalone `fast` opt-in still surfaces.
66+
const variants = buildModelVariants(
67+
model([
68+
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
69+
{ id: "effort", values: [{ value: "low" }, { value: "high" }] },
70+
{ id: "fast", values: [{ value: "false" }, { value: "true" }] },
71+
]),
72+
);
73+
expect(variants).toEqual({
74+
low: { params: { effort: "low", fast: "false" } },
75+
high: { params: { effort: "high", fast: "false" } },
76+
fast: { params: { fast: "true" } },
77+
});
78+
});
79+
80+
it("does not suppress the boolean thinking variant for a zero-value effort enum", () => {
81+
// hasEffortEnum requires a non-empty enum; an effort param with no values
82+
// must not count as an enum, so the boolean `thinking` variant survives.
83+
const variants = buildModelVariants(
84+
model([
85+
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
86+
{ id: "effort", values: [] },
87+
]),
88+
);
89+
expect(variants).toEqual({ thinking: { params: { thinking: "true" } } });
90+
});
91+
92+
it("does not emit a thinking variant when the boolean lacks a 'true' value", () => {
93+
// Boolean `thinking=["false"]` has nothing to opt INTO; combined with an
94+
// effort enum the result is purely the effort variants.
95+
const variants = buildModelVariants(
96+
model([
97+
{ id: "thinking", values: [{ value: "false" }] },
98+
{ id: "effort", values: [{ value: "low" }] },
99+
]),
100+
);
101+
expect(variants).toEqual({ low: { params: { effort: "low" } } });
102+
});
103+
104+
it("pins current behavior for a mixed boolean+enum reasoning param", () => {
105+
// A single reasoning param mixing boolean sentinels with effort values is
106+
// classified non-boolean (isBooleanParam requires EVERY value be a sentinel),
107+
// so it flows through the enum branch and emits literal false/true variants.
108+
// Not a real catalog shape today; pinned so a future change is caught.
109+
const variants = buildModelVariants(
110+
model([
111+
{ id: "reasoning", values: [{ value: "false" }, { value: "true" }, { value: "high" }] },
112+
]),
113+
);
114+
expect(variants).toEqual({
115+
false: { params: { reasoning: "false" } },
116+
true: { params: { reasoning: "true" } },
117+
high: { params: { reasoning: "high" } },
118+
});
119+
});
120+
44121
it("prefixes a value key on collision between two enum params", () => {
45122
const variants = buildModelVariants(
46123
model([

0 commit comments

Comments
 (0)