Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -98,6 +98,13 @@ export interface OpencodeModelConfigEntry {
* through which cursor model variants reach the picker.
*/
variants: Record<string, CursorVariant>;
/**
* 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<string, string> };
}

/**
Expand All @@ -108,6 +115,7 @@ export interface OpencodeModelConfigEntry {
export function toOpencodeModels(items: ModelListItem[]): Record<string, OpencodeModelConfigEntry> {
const out: Record<string, OpencodeModelConfigEntry> = {};
for (const item of items) {
const params = defaultModelParams(item);
out[item.id] = {
id: item.id,
name: item.displayName || item.id,
Expand All @@ -116,6 +124,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
temperature: false,
tool_call: true,
variants: buildModelVariants(item),
options: Object.keys(params).length > 0 ? { params } : {},
};
}
return out;
Expand Down
81 changes: 63 additions & 18 deletions src/model-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelListItem["parameters"]>[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<string, string> {
const out: Record<string, string> = {};
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<string, CursorVariant> {
const out: Record<string, CursorVariant> = {};
// 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;
Expand Down
5 changes: 3 additions & 2 deletions src/plugin/model-v2.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -26,6 +26,7 @@ export function providerNpm(): string {
export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2> {
const out: Record<string, ModelV2> = {};
for (const item of items) {
const params = defaultModelParams(item);
out[item.id] = {
id: item.id,
providerID: PROVIDER_ID,
Expand All @@ -43,7 +44,7 @@ export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2>
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"],
Expand Down
17 changes: 17 additions & 0 deletions test/model-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
22 changes: 22 additions & 0 deletions test/model-v2.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
48 changes: 43 additions & 5 deletions test/model-variants.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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({});
});
});