Skip to content

Commit 2754e9d

Browse files
fix(models): default Cursor fast tier OFF, add picker toggle (#23)
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.<id>.options.params.fast.
1 parent 488e93a commit 2754e9d

8 files changed

Lines changed: 175 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Fixed: Cursor's `fast` tier is no longer silently forced on.** The variant
8+
builder only mapped reasoning/effort params and dropped Cursor's `fast` toggle
9+
entirely, so it never reached `providerOptions.cursor`. Because Cursor marks
10+
the **default** variant of several models as `fast: true` (composer-2.5,
11+
composer-2, and the gpt-*-codex line), omitting the param meant opencode
12+
silently ran the fast tier with no way to opt out. Now `fast` defaults OFF —
13+
fast-capable models seed `options.params.fast = "false"` (sent every turn, and
14+
pinned into each reasoning variant so picking a reasoning level can't re-enable
15+
it) — and a `fast` picker variant lets you opt back in. Override per model via
16+
`provider.cursor.models.<id>.options.params.fast`.
717
- **Fingerprint-guarded session reuse, now the default (`session: "auto"`).**
818
Previously the provider created a fresh Cursor agent every turn and re-sent
919
the whole transcript (robust but cache-hostile and increasingly costly as a

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,13 @@ survives opencode restarts.
151151

152152
### Per-request controls (`mode`, thinking level)
153153

154-
The plugin auto-generates model variants for each reasoning/effort level a model advertises.
155-
Selecting a variant in the model picker sends its settings through `providerOptions.cursor`.
154+
The plugin auto-generates model variants for each reasoning/effort level a model advertises,
155+
plus a `fast` toggle for models that expose Cursor's fast tier. Selecting a variant in the
156+
model picker sends its settings through `providerOptions.cursor`.
157+
158+
`fast` defaults **off** (even though Cursor's own default is `fast: true` for some models, e.g.
159+
Composer and the codex line) so opencode never silently runs the fast tier — pick the `fast`
160+
variant to opt in, or set it per model under `options.params.fast` below.
156161

157162
opencode's **plan agent** (`Tab`) maps to Cursor's plan mode automatically — no manual config
158163
needed.

src/model-discovery.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js";
33
import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js";
44
import { FALLBACK_MODELS } from "./fallback-models.js";
55
import { loadCursorSdk } from "./cursor-runtime.js";
6-
import { buildModelVariants, type CursorVariant } from "./model-variants.js";
6+
import { buildModelVariants, defaultModelParams, type CursorVariant } from "./model-variants.js";
77

88
export type ModelSource = "live" | "cache" | "fallback";
99

@@ -98,6 +98,13 @@ export interface OpencodeModelConfigEntry {
9898
* through which cursor model variants reach the picker.
9999
*/
100100
variants: Record<string, CursorVariant>;
101+
/**
102+
* Default `providerOptions.cursor` for the model, merged into every request
103+
* unless a variant overrides it. Carries the non-reasoning boolean defaults
104+
* (e.g. `{ params: { fast: "false" } }`) so the provider never silently runs
105+
* Cursor's server-side `fast` default. See {@link defaultModelParams}.
106+
*/
107+
options: { params?: Record<string, string> };
101108
}
102109

103110
/**
@@ -108,6 +115,7 @@ export interface OpencodeModelConfigEntry {
108115
export function toOpencodeModels(items: ModelListItem[]): Record<string, OpencodeModelConfigEntry> {
109116
const out: Record<string, OpencodeModelConfigEntry> = {};
110117
for (const item of items) {
118+
const params = defaultModelParams(item);
111119
out[item.id] = {
112120
id: item.id,
113121
name: item.displayName || item.id,
@@ -116,6 +124,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
116124
temperature: false,
117125
tool_call: true,
118126
variants: buildModelVariants(item),
127+
options: Object.keys(params).length > 0 ? { params } : {},
119128
};
120129
}
121130
return out;

src/model-variants.ts

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,38 +13,83 @@ export interface CursorVariant {
1313
const REASONING_PARAM = /think|reason|effort/i;
1414
const BOOLEAN_VALUES = new Set(["true", "false"]);
1515

16+
function paramValues(param: NonNullable<ModelListItem["parameters"]>[number]): string[] {
17+
return (param.values ?? []).map((v) => v.value);
18+
}
19+
20+
function isBooleanParam(values: string[]): boolean {
21+
return values.length > 0 && values.every((v) => BOOLEAN_VALUES.has(v));
22+
}
23+
24+
/**
25+
* Params opencode must send by DEFAULT for this model — i.e. when the user has
26+
* NOT picked a variant. Non-reasoning boolean toggles (notably Cursor's `fast`)
27+
* are pinned OFF here so the provider never silently inherits Cursor's
28+
* server-side default, which is `fast: true` for several models (composer-*,
29+
* gpt-*-codex). The user opts back IN via the matching picker variant.
30+
*
31+
* Seeded into each model's opencode `options.params` (see `toOpencodeModels` /
32+
* `buildModelV2Map`); {@link resolveControls} merges it into the request.
33+
*/
34+
export function defaultModelParams(item: ModelListItem): Record<string, string> {
35+
const out: Record<string, string> = {};
36+
for (const param of item.parameters ?? []) {
37+
if (REASONING_PARAM.test(param.id)) continue;
38+
if (isBooleanParam(paramValues(param))) out[param.id] = "false";
39+
}
40+
return out;
41+
}
42+
1643
/**
1744
* Derive opencode model variants from a Cursor model's parameters so the
18-
* variant picker can expose thinking/reasoning levels. Each variant's object is
19-
* exactly what {@link resolveControls} consumes. Plan mode is NOT a variant:
20-
* opencode's plan agent (Tab) is mapped to Cursor's plan mode by the plugin's
21-
* `chat.params` hook.
45+
* variant picker can expose thinking/reasoning levels plus the `fast` toggle.
46+
* Each variant's object is exactly what {@link resolveControls} consumes. Plan
47+
* mode is NOT a variant: opencode's plan agent (Tab) is mapped to Cursor's plan
48+
* mode by the plugin's `chat.params` hook.
49+
*
50+
* Every variant for a fast-capable model carries an explicit `fast` value
51+
* (reasoning variants pin it OFF via {@link defaultModelParams}; the `fast`
52+
* variant turns it ON) so a selection never depends on Cursor's server-side
53+
* default for an omitted param.
2254
*/
2355
export function buildModelVariants(item: ModelListItem): Record<string, CursorVariant> {
2456
const out: Record<string, CursorVariant> = {};
57+
// Non-reasoning boolean defaults (e.g. { fast: "false" }), pinned into every
58+
// reasoning variant so picking a reasoning level never re-enables fast.
59+
const defaults = defaultModelParams(item);
2560

2661
for (const param of item.parameters ?? []) {
27-
if (!REASONING_PARAM.test(param.id)) continue;
28-
const values = (param.values ?? []).map((v) => v.value);
62+
const values = paramValues(param);
2963
if (values.length === 0) continue;
64+
const boolean = isBooleanParam(values);
65+
66+
if (REASONING_PARAM.test(param.id)) {
67+
if (boolean) {
68+
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
69+
// variant names are meaningless in the picker — surface a single
70+
// variant named after the param that switches it on. "Off" is the
71+
// model's default (no variant selected).
72+
if (values.includes("true")) {
73+
out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } };
74+
}
75+
continue;
76+
}
3077

31-
if (values.every((v) => BOOLEAN_VALUES.has(v))) {
32-
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
33-
// variant names are meaningless in the picker — surface a single
34-
// variant named after the param that switches it on. "Off" is the
35-
// model's default (no variant selected).
36-
if (values.includes("true")) {
37-
out[param.id.toLowerCase()] = { params: { [param.id]: "true" } };
78+
for (const value of values) {
79+
// Key by the bare value (e.g. "high"); prefix with the param id only
80+
// when two params share a value (e.g. reasoning-low vs effort-low).
81+
const key = out[value] === undefined ? value : `${param.id}-${value}`;
82+
out[key] = { params: { ...defaults, [param.id]: value } };
3883
}
3984
continue;
4085
}
4186

42-
for (const value of values) {
43-
// Key by the bare value (e.g. "high"); prefix with the param id only
44-
// when two params share a value (e.g. reasoning-low vs effort-low).
45-
const key = out[value] === undefined ? value : `${param.id}-${value}`;
46-
out[key] = { params: { [param.id]: value } };
87+
// Non-reasoning boolean toggle (e.g. Cursor's `fast`). Default is OFF (see
88+
// defaultModelParams); expose a single opt-in variant that turns it ON.
89+
if (boolean && values.includes("true")) {
90+
out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } };
4791
}
92+
// Non-reasoning enum params (e.g. `context`) remain unsupported in the picker.
4893
}
4994

5095
return out;

src/plugin/model-v2.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
22
import type { ModelListItem } from "@cursor/sdk";
33
import { modelSupportsReasoning } from "../model-discovery.js";
4-
import { buildModelVariants } from "../model-variants.js";
4+
import { buildModelVariants, defaultModelParams } from "../model-variants.js";
55

66
export const PROVIDER_ID = "cursor";
77
export const NPM_PACKAGE = "@stablekernel/opencode-cursor";
@@ -26,6 +26,7 @@ export function providerNpm(): string {
2626
export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2> {
2727
const out: Record<string, ModelV2> = {};
2828
for (const item of items) {
29+
const params = defaultModelParams(item);
2930
out[item.id] = {
3031
id: item.id,
3132
providerID: PROVIDER_ID,
@@ -43,7 +44,7 @@ export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2>
4344
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
4445
limit: { context: 200_000, output: 32_000 },
4546
status: "active",
46-
options: {},
47+
options: Object.keys(params).length > 0 ? { params } : {},
4748
headers: {},
4849
release_date: "",
4950
variants: buildModelVariants(item) as ModelV2["variants"],

test/model-discovery.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ describe("toOpencodeModels", () => {
6262
// No reasoning params → no variants (plan is an opencode agent, not a variant).
6363
expect(map["plain"]!.variants).toEqual({});
6464
});
65+
66+
it("seeds the fast-off default into a fast-capable model's options.params", () => {
67+
// The config-seeded models map is the only channel through which per-model
68+
// defaults reach opencode, so a fast-capable model must default `fast` OFF
69+
// here (sent on every turn unless the user picks the `fast` variant).
70+
const map = toOpencodeModels([
71+
{
72+
id: "composer-2.5",
73+
displayName: "Composer 2.5",
74+
parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true" }] }],
75+
},
76+
{ id: "plain", displayName: "Plain Model" },
77+
]);
78+
expect(map["composer-2.5"]!.options).toEqual({ params: { fast: "false" } });
79+
expect(map["composer-2.5"]!.variants).toEqual({ fast: { params: { fast: "true" } } });
80+
expect(map["plain"]!.options).toEqual({});
81+
});
6582
});
6683

6784
describe("discoverModels without a key", () => {

test/model-v2.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ModelListItem } from "@cursor/sdk";
3+
import { buildModelV2Map } from "../src/plugin/model-v2.js";
4+
5+
describe("buildModelV2Map", () => {
6+
it("seeds the fast-off default into options and exposes a fast opt-in variant", () => {
7+
const map = buildModelV2Map([
8+
{
9+
id: "composer-2.5",
10+
displayName: "Composer 2.5",
11+
parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true" }] }],
12+
} satisfies ModelListItem,
13+
]);
14+
expect(map["composer-2.5"]!.options).toEqual({ params: { fast: "false" } });
15+
expect(map["composer-2.5"]!.variants).toEqual({ fast: { params: { fast: "true" } } });
16+
});
17+
18+
it("leaves options empty for models without non-reasoning booleans", () => {
19+
const map = buildModelV2Map([{ id: "plain", displayName: "Plain" }]);
20+
expect(map["plain"]!.options).toEqual({});
21+
});
22+
});

test/model-variants.test.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import type { ModelListItem } from "@cursor/sdk";
3-
import { buildModelVariants } from "../src/model-variants.js";
3+
import { buildModelVariants, defaultModelParams } from "../src/model-variants.js";
44

55
function model(parameters: ModelListItem["parameters"]): ModelListItem {
66
return { id: "m", displayName: "M", parameters };
@@ -54,19 +54,57 @@ describe("buildModelVariants", () => {
5454
});
5555
});
5656

57-
it("ignores non-reasoning params and offers no plan variant", () => {
58-
// fast/context are not reasoning levels; plan is opencode's plan AGENT
59-
// (Tab), mapped via the chat.params hook — not a model variant.
57+
it("surfaces a non-reasoning boolean (fast) as an opt-in toggle; ignores enum context", () => {
58+
// `fast` is Cursor's fast-tier toggle. It is not a reasoning level, but the
59+
// user must be able to opt INTO it from the picker (default is off, sent
60+
// explicitly via defaultModelParams). `context` is an enum, still unsupported.
61+
// plan is opencode's plan AGENT (Tab), mapped via the chat.params hook.
6062
const variants = buildModelVariants(
6163
model([
6264
{ id: "fast", values: [{ value: "false" }, { value: "true" }] },
6365
{ id: "context", values: [{ value: "300k" }, { value: "1m" }] },
6466
]),
6567
);
66-
expect(variants).toEqual({});
68+
expect(variants).toEqual({ fast: { params: { fast: "true" } } });
69+
});
70+
71+
it("bakes the fast-off default into reasoning variants for fast-capable models", () => {
72+
// Cursor defaults `fast` to true for several models (composer/codex). When
73+
// the user picks a reasoning level we must pin fast OFF explicitly, so a
74+
// reasoning selection never silently falls back to Cursor's fast default.
75+
const variants = buildModelVariants(
76+
model([
77+
{ id: "effort", values: [{ value: "low" }, { value: "high" }] },
78+
{ id: "fast", values: [{ value: "false" }, { value: "true" }] },
79+
]),
80+
);
81+
expect(variants).toEqual({
82+
low: { params: { effort: "low", fast: "false" } },
83+
high: { params: { effort: "high", fast: "false" } },
84+
fast: { params: { fast: "true" } },
85+
});
6786
});
6887

6988
it("returns no variants for a model without parameters", () => {
7089
expect(buildModelVariants(model(undefined))).toEqual({});
7190
});
7291
});
92+
93+
describe("defaultModelParams", () => {
94+
it("defaults non-reasoning boolean params (fast) OFF", () => {
95+
expect(
96+
defaultModelParams(
97+
model([{ id: "fast", values: [{ value: "false" }, { value: "true" }] }]),
98+
),
99+
).toEqual({ fast: "false" });
100+
});
101+
102+
it("returns no defaults for models without non-reasoning booleans", () => {
103+
expect(
104+
defaultModelParams(
105+
model([{ id: "effort", values: [{ value: "low" }, { value: "high" }] }]),
106+
),
107+
).toEqual({});
108+
expect(defaultModelParams(model(undefined))).toEqual({});
109+
});
110+
});

0 commit comments

Comments
 (0)