From 083dfbae580f6d47f0ff8053afb495f3f10a4f9c Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Tue, 16 Jun 2026 13:27:57 -0400 Subject: [PATCH] fix(models): default Cursor `fast` tier OFF, add picker toggle The variant builder only mapped reasoning/effort params and dropped Cursor's `fast` toggle, so it never reached providerOptions.cursor. Cursor marks the default variant of several models as fast:true (composer-2.5, composer-2, gpt-*-codex), so omitting the param ran the fast tier with no opt-out. Now `fast` defaults off: fast-capable models seed options.params.fast="false" (sent every turn, and pinned into each reasoning variant so picking a reasoning level can't re-enable it), and a `fast` picker variant opts back in. Override per model via provider.cursor.models..options.params.fast. --- CHANGELOG.md | 10 +++++ README.md | 9 +++- src/model-discovery.ts | 11 ++++- src/model-variants.ts | 81 ++++++++++++++++++++++++++++-------- src/plugin/model-v2.ts | 5 ++- test/model-discovery.test.ts | 17 ++++++++ test/model-v2.test.ts | 22 ++++++++++ test/model-variants.test.ts | 48 ++++++++++++++++++--- 8 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 test/model-v2.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa7fb7..8d2aa57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Fixed: Cursor's `fast` tier is no longer silently forced on.** The variant + builder only mapped reasoning/effort params and dropped Cursor's `fast` toggle + entirely, so it never reached `providerOptions.cursor`. Because Cursor marks + the **default** variant of several models as `fast: true` (composer-2.5, + composer-2, and the gpt-*-codex line), omitting the param meant opencode + silently ran the fast tier with no way to opt out. Now `fast` defaults OFF — + fast-capable models seed `options.params.fast = "false"` (sent every turn, and + pinned into each reasoning variant so picking a reasoning level can't re-enable + it) — and a `fast` picker variant lets you opt back in. Override per model via + `provider.cursor.models..options.params.fast`. - **Fingerprint-guarded session reuse, now the default (`session: "auto"`).** Previously the provider created a fresh Cursor agent every turn and re-sent the whole transcript (robust but cache-hostile and increasingly costly as a diff --git a/README.md b/README.md index 1f0303b..0b858ee 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,13 @@ survives opencode restarts. ### Per-request controls (`mode`, thinking level) -The plugin auto-generates model variants for each reasoning/effort level a model advertises. -Selecting a variant in the model picker sends its settings through `providerOptions.cursor`. +The plugin auto-generates model variants for each reasoning/effort level a model advertises, +plus a `fast` toggle for models that expose Cursor's fast tier. Selecting a variant in the +model picker sends its settings through `providerOptions.cursor`. + +`fast` defaults **off** (even though Cursor's own default is `fast: true` for some models, e.g. +Composer and the codex line) so opencode never silently runs the fast tier — pick the `fast` +variant to opt in, or set it per model under `options.params.fast` below. opencode's **plan agent** (`Tab`) maps to Cursor's plan mode automatically — no manual config needed. diff --git a/src/model-discovery.ts b/src/model-discovery.ts index 268fb5e..f741c3c 100644 --- a/src/model-discovery.ts +++ b/src/model-discovery.ts @@ -3,7 +3,7 @@ import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js"; import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js"; import { FALLBACK_MODELS } from "./fallback-models.js"; import { loadCursorSdk } from "./cursor-runtime.js"; -import { buildModelVariants, type CursorVariant } from "./model-variants.js"; +import { buildModelVariants, defaultModelParams, type CursorVariant } from "./model-variants.js"; export type ModelSource = "live" | "cache" | "fallback"; @@ -98,6 +98,13 @@ export interface OpencodeModelConfigEntry { * through which cursor model variants reach the picker. */ variants: Record; + /** + * Default `providerOptions.cursor` for the model, merged into every request + * unless a variant overrides it. Carries the non-reasoning boolean defaults + * (e.g. `{ params: { fast: "false" } }`) so the provider never silently runs + * Cursor's server-side `fast` default. See {@link defaultModelParams}. + */ + options: { params?: Record }; } /** @@ -108,6 +115,7 @@ export interface OpencodeModelConfigEntry { export function toOpencodeModels(items: ModelListItem[]): Record { const out: Record = {}; for (const item of items) { + const params = defaultModelParams(item); out[item.id] = { id: item.id, name: item.displayName || item.id, @@ -116,6 +124,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record 0 ? { params } : {}, }; } return out; diff --git a/src/model-variants.ts b/src/model-variants.ts index 77f1dce..f08fe83 100644 --- a/src/model-variants.ts +++ b/src/model-variants.ts @@ -13,38 +13,83 @@ export interface CursorVariant { const REASONING_PARAM = /think|reason|effort/i; const BOOLEAN_VALUES = new Set(["true", "false"]); +function paramValues(param: NonNullable[number]): string[] { + return (param.values ?? []).map((v) => v.value); +} + +function isBooleanParam(values: string[]): boolean { + return values.length > 0 && values.every((v) => BOOLEAN_VALUES.has(v)); +} + +/** + * Params opencode must send by DEFAULT for this model — i.e. when the user has + * NOT picked a variant. Non-reasoning boolean toggles (notably Cursor's `fast`) + * are pinned OFF here so the provider never silently inherits Cursor's + * server-side default, which is `fast: true` for several models (composer-*, + * gpt-*-codex). The user opts back IN via the matching picker variant. + * + * Seeded into each model's opencode `options.params` (see `toOpencodeModels` / + * `buildModelV2Map`); {@link resolveControls} merges it into the request. + */ +export function defaultModelParams(item: ModelListItem): Record { + const out: Record = {}; + for (const param of item.parameters ?? []) { + if (REASONING_PARAM.test(param.id)) continue; + if (isBooleanParam(paramValues(param))) out[param.id] = "false"; + } + return out; +} + /** * Derive opencode model variants from a Cursor model's parameters so the - * variant picker can expose thinking/reasoning levels. Each variant's object is - * exactly what {@link resolveControls} consumes. Plan mode is NOT a variant: - * opencode's plan agent (Tab) is mapped to Cursor's plan mode by the plugin's - * `chat.params` hook. + * variant picker can expose thinking/reasoning levels plus the `fast` toggle. + * Each variant's object is exactly what {@link resolveControls} consumes. Plan + * mode is NOT a variant: opencode's plan agent (Tab) is mapped to Cursor's plan + * mode by the plugin's `chat.params` hook. + * + * Every variant for a fast-capable model carries an explicit `fast` value + * (reasoning variants pin it OFF via {@link defaultModelParams}; the `fast` + * variant turns it ON) so a selection never depends on Cursor's server-side + * default for an omitted param. */ export function buildModelVariants(item: ModelListItem): Record { const out: Record = {}; + // Non-reasoning boolean defaults (e.g. { fast: "false" }), pinned into every + // reasoning variant so picking a reasoning level never re-enables fast. + const defaults = defaultModelParams(item); for (const param of item.parameters ?? []) { - if (!REASONING_PARAM.test(param.id)) continue; - const values = (param.values ?? []).map((v) => v.value); + const values = paramValues(param); if (values.length === 0) continue; + const boolean = isBooleanParam(values); + + if (REASONING_PARAM.test(param.id)) { + if (boolean) { + // 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")) { + out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } }; + } + continue; + } - if (values.every((v) => BOOLEAN_VALUES.has(v))) { - // 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")) { - out[param.id.toLowerCase()] = { params: { [param.id]: "true" } }; + for (const value of values) { + // Key by the bare value (e.g. "high"); prefix with the param id only + // when two params share a value (e.g. reasoning-low vs effort-low). + const key = out[value] === undefined ? value : `${param.id}-${value}`; + out[key] = { params: { ...defaults, [param.id]: value } }; } continue; } - for (const value of values) { - // Key by the bare value (e.g. "high"); prefix with the param id only - // when two params share a value (e.g. reasoning-low vs effort-low). - const key = out[value] === undefined ? value : `${param.id}-${value}`; - out[key] = { params: { [param.id]: value } }; + // Non-reasoning boolean toggle (e.g. Cursor's `fast`). Default is OFF (see + // defaultModelParams); expose a single opt-in variant that turns it ON. + if (boolean && values.includes("true")) { + out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } }; } + // Non-reasoning enum params (e.g. `context`) remain unsupported in the picker. } return out; diff --git a/src/plugin/model-v2.ts b/src/plugin/model-v2.ts index 5e5d3f1..4d15fed 100644 --- a/src/plugin/model-v2.ts +++ b/src/plugin/model-v2.ts @@ -1,7 +1,7 @@ import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import type { ModelListItem } from "@cursor/sdk"; import { modelSupportsReasoning } from "../model-discovery.js"; -import { buildModelVariants } from "../model-variants.js"; +import { buildModelVariants, defaultModelParams } from "../model-variants.js"; export const PROVIDER_ID = "cursor"; export const NPM_PACKAGE = "@stablekernel/opencode-cursor"; @@ -26,6 +26,7 @@ export function providerNpm(): string { export function buildModelV2Map(items: ModelListItem[]): Record { const out: Record = {}; for (const item of items) { + const params = defaultModelParams(item); out[item.id] = { id: item.id, providerID: PROVIDER_ID, @@ -43,7 +44,7 @@ export function buildModelV2Map(items: ModelListItem[]): Record cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, limit: { context: 200_000, output: 32_000 }, status: "active", - options: {}, + options: Object.keys(params).length > 0 ? { params } : {}, headers: {}, release_date: "", variants: buildModelVariants(item) as ModelV2["variants"], diff --git a/test/model-discovery.test.ts b/test/model-discovery.test.ts index aa84494..12b0081 100644 --- a/test/model-discovery.test.ts +++ b/test/model-discovery.test.ts @@ -62,6 +62,23 @@ describe("toOpencodeModels", () => { // No reasoning params → no variants (plan is an opencode agent, not a variant). expect(map["plain"]!.variants).toEqual({}); }); + + it("seeds the fast-off default into a fast-capable model's options.params", () => { + // The config-seeded models map is the only channel through which per-model + // defaults reach opencode, so a fast-capable model must default `fast` OFF + // here (sent on every turn unless the user picks the `fast` variant). + const map = toOpencodeModels([ + { + id: "composer-2.5", + displayName: "Composer 2.5", + parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true" }] }], + }, + { id: "plain", displayName: "Plain Model" }, + ]); + expect(map["composer-2.5"]!.options).toEqual({ params: { fast: "false" } }); + expect(map["composer-2.5"]!.variants).toEqual({ fast: { params: { fast: "true" } } }); + expect(map["plain"]!.options).toEqual({}); + }); }); describe("discoverModels without a key", () => { diff --git a/test/model-v2.test.ts b/test/model-v2.test.ts new file mode 100644 index 0000000..16f01ad --- /dev/null +++ b/test/model-v2.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import type { ModelListItem } from "@cursor/sdk"; +import { buildModelV2Map } from "../src/plugin/model-v2.js"; + +describe("buildModelV2Map", () => { + it("seeds the fast-off default into options and exposes a fast opt-in variant", () => { + const map = buildModelV2Map([ + { + id: "composer-2.5", + displayName: "Composer 2.5", + parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true" }] }], + } satisfies ModelListItem, + ]); + expect(map["composer-2.5"]!.options).toEqual({ params: { fast: "false" } }); + expect(map["composer-2.5"]!.variants).toEqual({ fast: { params: { fast: "true" } } }); + }); + + it("leaves options empty for models without non-reasoning booleans", () => { + const map = buildModelV2Map([{ id: "plain", displayName: "Plain" }]); + expect(map["plain"]!.options).toEqual({}); + }); +}); diff --git a/test/model-variants.test.ts b/test/model-variants.test.ts index 7db464b..b84ed56 100644 --- a/test/model-variants.test.ts +++ b/test/model-variants.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ModelListItem } from "@cursor/sdk"; -import { buildModelVariants } from "../src/model-variants.js"; +import { buildModelVariants, defaultModelParams } from "../src/model-variants.js"; function model(parameters: ModelListItem["parameters"]): ModelListItem { return { id: "m", displayName: "M", parameters }; @@ -54,19 +54,57 @@ describe("buildModelVariants", () => { }); }); - it("ignores non-reasoning params and offers no plan variant", () => { - // fast/context are not reasoning levels; plan is opencode's plan AGENT - // (Tab), mapped via the chat.params hook — not a model variant. + it("surfaces a non-reasoning boolean (fast) as an opt-in toggle; ignores enum context", () => { + // `fast` is Cursor's fast-tier toggle. It is not a reasoning level, but the + // user must be able to opt INTO it from the picker (default is off, sent + // explicitly via defaultModelParams). `context` is an enum, still unsupported. + // plan is opencode's plan AGENT (Tab), mapped via the chat.params hook. const variants = buildModelVariants( model([ { id: "fast", values: [{ value: "false" }, { value: "true" }] }, { id: "context", values: [{ value: "300k" }, { value: "1m" }] }, ]), ); - expect(variants).toEqual({}); + expect(variants).toEqual({ fast: { params: { fast: "true" } } }); + }); + + it("bakes the fast-off default into reasoning variants for fast-capable models", () => { + // Cursor defaults `fast` to true for several models (composer/codex). When + // the user picks a reasoning level we must pin fast OFF explicitly, so a + // reasoning selection never silently falls back to Cursor's fast default. + const variants = buildModelVariants( + model([ + { 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("returns no variants for a model without parameters", () => { expect(buildModelVariants(model(undefined))).toEqual({}); }); }); + +describe("defaultModelParams", () => { + it("defaults non-reasoning boolean params (fast) OFF", () => { + expect( + defaultModelParams( + model([{ id: "fast", values: [{ value: "false" }, { value: "true" }] }]), + ), + ).toEqual({ fast: "false" }); + }); + + it("returns no defaults for models without non-reasoning booleans", () => { + expect( + defaultModelParams( + model([{ id: "effort", values: [{ value: "low" }, { value: "high" }] }]), + ), + ).toEqual({}); + expect(defaultModelParams(model(undefined))).toEqual({}); + }); +});