Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/model-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export function buildModelVariants(item: ModelListItem): Record<string, CursorVa
// reasoning variant so picking a reasoning level never re-enables fast.
const defaults = defaultModelParams(item);

// Pre-pass: does any reasoning param expose a non-boolean effort enum (e.g.
// ["low","medium","high","xhigh","max"])? When it does, a coexisting boolean
// reasoning toggle (Cursor's `thinking=["false","true"]` on claude-* models)
// is redundant — selecting any effort level already enables reasoning — and
// surfacing it would add a stray `thinking` variant the standard opencode
// providers don't show. Suppress the boolean variant for parity. Order-
// independent: the enum may be declared before or after the boolean.
const hasEffortEnum = (item.parameters ?? []).some(
(p) => REASONING_PARAM.test(p.id) && !isBooleanParam(paramValues(p)) && paramValues(p).length > 0,
);

for (const param of item.parameters ?? []) {
const values = paramValues(param);
if (values.length === 0) continue;
Expand All @@ -68,8 +79,9 @@ export function buildModelVariants(item: ModelListItem): Record<string, CursorVa
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
// variant names are meaningless in the picker — surface a single
// variant named after the param that switches it on. "Off" is the
// model's default (no variant selected).
if (values.includes("true")) {
// model's default (no variant selected). Skipped entirely when an
// effort enum coexists (see hasEffortEnum above).
if (!hasEffortEnum && values.includes("true")) {
out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } };
}
continue;
Expand Down
81 changes: 79 additions & 2 deletions test/model-variants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,97 @@ describe("buildModelVariants", () => {
});
});

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

it("suppresses the boolean thinking variant regardless of param order", () => {
// Order-independence guard for the hasEffortEnum pre-pass: the effort enum
// declared AFTER the boolean must still suppress it, and vice versa.
const enumFirst = buildModelVariants(
model([
{ id: "effort", values: [{ value: "low" }, { value: "max" }] },
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
]),
);
expect(enumFirst).toEqual({
low: { params: { effort: "low" } },
max: { params: { effort: "max" } },
});
});

it("composes suppression with fast defaults (production claude-via-Cursor shape)", () => {
// The real catalog model: boolean `thinking` + effort enum + `fast`. The
// `thinking` variant is suppressed, each effort variant bakes `fast` OFF
// (defaultModelParams), and a standalone `fast` opt-in still surfaces.
const variants = buildModelVariants(
model([
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
{ id: "effort", values: [{ value: "low" }, { value: "high" }] },
{ id: "fast", values: [{ value: "false" }, { value: "true" }] },
]),
);
expect(variants).toEqual({
low: { params: { effort: "low", fast: "false" } },
high: { params: { effort: "high", fast: "false" } },
fast: { params: { fast: "true" } },
});
});

it("does not suppress the boolean thinking variant for a zero-value effort enum", () => {
// hasEffortEnum requires a non-empty enum; an effort param with no values
// must not count as an enum, so the boolean `thinking` variant survives.
const variants = buildModelVariants(
model([
{ id: "thinking", values: [{ value: "false" }, { value: "true" }] },
{ id: "effort", values: [] },
]),
);
expect(variants).toEqual({ thinking: { params: { thinking: "true" } } });
});

it("does not emit a thinking variant when the boolean lacks a 'true' value", () => {
// Boolean `thinking=["false"]` has nothing to opt INTO; combined with an
// effort enum the result is purely the effort variants.
const variants = buildModelVariants(
model([
{ id: "thinking", values: [{ value: "false" }] },
{ id: "effort", values: [{ value: "low" }] },
]),
);
expect(variants).toEqual({ low: { params: { effort: "low" } } });
});

it("pins current behavior for a mixed boolean+enum reasoning param", () => {
// A single reasoning param mixing boolean sentinels with effort values is
// classified non-boolean (isBooleanParam requires EVERY value be a sentinel),
// so it flows through the enum branch and emits literal false/true variants.
// Not a real catalog shape today; pinned so a future change is caught.
const variants = buildModelVariants(
model([
{ id: "reasoning", values: [{ value: "false" }, { value: "true" }, { value: "high" }] },
]),
);
expect(variants).toEqual({
false: { params: { reasoning: "false" } },
true: { params: { reasoning: "true" } },
high: { params: { reasoning: "high" } },
});
});

it("prefixes a value key on collision between two enum params", () => {
const variants = buildModelVariants(
model([
Expand Down