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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- **A `thinking` level now reaches models that read `effort`/`reasoning`
(#119).** Cursor ignores params a model doesn't advertise, so
`"thinking": "medium"` on `grok-4.6` silently ran at Cursor's default
(`high`). When the model's discovered params show `thinking` can't carry
the value, the provider sends it as the model's reasoning param instead
(`effort: medium`; `xhigh` maps to `extra-high` where Cursor spells it that
way) and logs a one-time warning. An explicit `effort`/`reasoning` value
still wins.

## [0.10.1] — 2026-10-28

Patch release: opencode now counts and prices non-cached input tokens for
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ param and falls back to its own default (e.g. `high` effort). `grok-4.6` uses `e
`composer-2.5` has only `fast`. Run `cursor_refresh_models` to list each model id
with its param ids and accepted values.

A `thinking` level the model can't take (e.g. `"thinking": "medium"` on `grok-4.6`) is moved to
the model's reasoning param (`effort: medium`) when that param accepts the value, and the plugin
logs a one-time warning. This needs the model's params from a discovered catalog; an explicit
`effort`/`reasoning` value always wins.

To set controls statically per model:

```json
Expand Down
6 changes: 3 additions & 3 deletions src/model-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ export interface CursorVariant {
mode?: "agent" | "plan";
}

const REASONING_PARAM = /think|reason|effort/i;
export const REASONING_PARAM = /think|reason|effort/i;
const BOOLEAN_VALUES = new Set(["true", "false"]);

function paramValues(param: NonNullable<ModelListItem["parameters"]>[number]): string[] {
export function paramValues(param: NonNullable<ModelListItem["parameters"]>[number]): string[] {
return (param.values ?? []).map((v) => v.value);
}

function isBooleanParam(values: string[]): boolean {
export function isBooleanParam(values: string[]): boolean {
return values.length > 0 && values.every((v) => BOOLEAN_VALUES.has(v));
}

Expand Down
55 changes: 52 additions & 3 deletions src/provider/controls.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { AgentModeOption, ModelSelection } from "@cursor/sdk";
import type { AgentModeOption, ModelListItem, ModelSelection } from "@cursor/sdk";
import { isBooleanParam, paramValues, REASONING_PARAM } from "../model-variants.js";

type ModelParameters = NonNullable<ModelListItem["parameters"]>;

/** Per-model static control defaults (from provider/model config options). */
export interface StaticControls {
Expand All @@ -11,11 +14,15 @@ export interface StaticControls {
* `{ fast: "false" }`, when a turn arrives with no params of its own.
*/
defaults?: Record<string, string>;
/** The model's advertised params, when known; lets a `thinking` level reach the param the model actually reads. */
parameters?: ModelParameters;
}

export interface ResolvedControls {
mode: AgentModeOption;
modelSelection: ModelSelection;
/** Param adjustments the user should know about (log once, not per turn). */
warnings: string[];
}

/**
Expand Down Expand Up @@ -47,7 +54,10 @@ function isMode(value: unknown): value is AgentModeOption {
* Recognized keys in `providerOptions.cursor`:
* - `mode`: "agent" | "plan"
* - `params`: Record<string,string> of Cursor model params (e.g. { effort: "high" })
* - `thinking`: string convenience, mapped to the `thinking` param if not already set
* - `thinking`: string convenience, mapped to the `thinking` param if not already set.
* When the model's params are known and `thinking` can't carry the value, it moves
* to the model's reasoning-level param (e.g. grok's `effort`): Cursor silently
* ignores params a model doesn't accept (#119).
*/
export function resolveControls(
modelId: string,
Expand All @@ -71,5 +81,44 @@ export function resolveControls(
params["thinking"] = po["thinking"];
}

return { mode, modelSelection: buildModelSelection(modelId, params) };
const warnings = reconcileThinking(modelId, params, staticControls.parameters);
return { mode, modelSelection: buildModelSelection(modelId, params), warnings };
}

function reconcileThinking(
modelId: string,
params: Record<string, string>,
parameters: ModelParameters | undefined,
): string[] {
const value = params["thinking"];
if (value === undefined || !parameters) return [];
const thinking = parameters.find((p) => p.id === "thinking");
if (thinking && paramValues(thinking).includes(value)) return [];

const why = thinking
? `${modelId}'s \`thinking\` param only accepts ${paramValues(thinking).join("|")}`
: `${modelId} has no \`thinking\` param`;
const levels = parameters.filter(
(p) => p.id !== "thinking" && REASONING_PARAM.test(p.id) && paramValues(p).length > 0 && !isBooleanParam(paramValues(p)),
);
const level = levels[0];
if (!level) return [`${why}; Cursor will ignore \`thinking: ${value}\`.`];
const explicit = levels.find((p) => params[p.id] !== undefined);
if (explicit) {
delete params["thinking"];
return [`${why}; ignored \`thinking: ${value}\` because \`${explicit.id}: ${params[explicit.id]}\` is set.`];
}
const accepted = paramValues(level);
// opencode's standard top tier is "xhigh"; some Cursor models spell it "extra-high".
const wire = accepted.includes(value)
? value
: value === "xhigh" && accepted.includes("extra-high")
? "extra-high"
: undefined;
if (!wire) {
return [`${why}; Cursor will ignore \`thinking: ${value}\`. Use ${level.id}=${accepted.join("|")}.`];
}
delete params["thinking"];
params[level.id] = wire;
return [`${why}; sent \`${level.id}: ${wire}\` instead.`];
}
18 changes: 17 additions & 1 deletion src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import { LoadAPIKeyError } from "@ai-sdk/provider";
import type {
AgentDefinition,
McpServerConfig,
ModelListItem,
SettingSource,
AgentModeOption,
SDKUserMessage,
} from "@cursor/sdk";
import { resolveCursorApiKey } from "../api-key.js";
import { FALLBACK_MODELS } from "../fallback-models.js";
import { readLatestModelCache } from "../model-cache.js";
import {
latestUserMessage,
promptToCursorMessage,
Expand Down Expand Up @@ -138,6 +141,17 @@ export class CursorLanguageModel implements LanguageModelV3 {
pluginLog("warn", message, { provider: this.provider });
}

/** This model's advertised params from the last discovered catalog; read once per instance. */
private parameters: ModelListItem["parameters"] | null | undefined;

private modelParameters(): ModelListItem["parameters"] {
if (this.parameters === undefined) {
const catalog = readLatestModelCache() ?? FALLBACK_MODELS;
this.parameters = catalog.find((m) => m.id === this.modelId)?.parameters ?? null;
}
return this.parameters ?? undefined;
}

private requireApiKey(): string {
const apiKey = resolveCursorApiKey(this.config.apiKey);
if (!apiKey) {
Expand All @@ -158,15 +172,17 @@ export class CursorLanguageModel implements LanguageModelV3 {
const providerOptions = options.providerOptions?.[this.provider] as
| Record<string, unknown>
| undefined;
const { mode, modelSelection } = resolveControls(
const { mode, modelSelection, warnings } = resolveControls(
this.modelId,
{
mode: this.config.mode,
params: this.config.params,
defaults: this.config.modelParamDefaults?.[this.modelId],
parameters: this.modelParameters(),
},
providerOptions,
);
for (const warning of warnings) this.warnOnce(warning);
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
pluginLog("debug", "model call", {
model: this.modelId,
Expand Down
79 changes: 79 additions & 0 deletions test/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,83 @@ describe("resolveControls", () => {
});
});

type Params = NonNullable<ModelListItem["parameters"]>;
const param = (id: string, values: string[]) => ({
id,
displayName: id,
values: values.map((value) => ({ value })),
});
const GROK_4_6: Params = [
param("effort", ["low", "medium", "high", "xhigh"]),
param("fast", ["false", "true"]),
];
const CLAUDE: Params = [param("thinking", ["false", "true"]), param("effort", ["low", "medium", "high", "max"])];
const GPT: Params = [param("reasoning", ["low", "medium", "high", "extra-high"])];

describe("resolveControls `thinking` on models without a matching thinking param (#119)", () => {
it("moves a configured params.thinking level to the model's effort param", () => {
const r = resolveControls(
"grok-4.6",
{ mode: "agent", parameters: GROK_4_6 },
{ params: { thinking: "medium", fast: "false" } },
);
expect(r.modelSelection.params).toEqual([
{ id: "fast", value: "false" },
{ id: "effort", value: "medium" },
]);
expect(r.warnings).toEqual([
expect.stringContaining("grok-4.6 has no `thinking` param; sent `effort: medium` instead"),
]);
});

it("moves the `thinking` convenience key too", () => {
const r = resolveControls("grok-4.6", { mode: "agent", parameters: GROK_4_6 }, { thinking: "high" });
expect(r.modelSelection.params).toEqual([{ id: "effort", value: "high" }]);
});

it("keeps a valid boolean thinking value on a model that has the param", () => {
const r = resolveControls("claude", { mode: "agent", parameters: CLAUDE }, { params: { thinking: "true" } });
expect(r.modelSelection.params).toEqual([{ id: "thinking", value: "true" }]);
expect(r.warnings).toEqual([]);
});

it("moves a level value off a boolean thinking param onto the effort enum", () => {
const r = resolveControls("claude", { mode: "agent", parameters: CLAUDE }, { params: { thinking: "max" } });
expect(r.modelSelection.params).toEqual([{ id: "effort", value: "max" }]);
});

it("lets an explicit effort win and drops the unusable thinking key", () => {
const r = resolveControls(
"grok-4.6",
{ mode: "agent", parameters: GROK_4_6 },
{ params: { thinking: "medium", effort: "low" } },
);
expect(r.modelSelection.params).toEqual([{ id: "effort", value: "low" }]);
expect(r.warnings).toEqual([expect.stringContaining("ignored `thinking: medium`")]);
});

it("treats an explicit value on any reasoning param as the user's choice", () => {
const both: Params = [param("reasoning", ["low", "high"]), param("effort", ["low", "medium"])];
const r = resolveControls("m", { mode: "agent", parameters: both }, { params: { thinking: "low", effort: "medium" } });
expect(r.modelSelection.params).toEqual([{ id: "effort", value: "medium" }]);
});

it("maps xhigh to Cursor's extra-high wire value", () => {
const r = resolveControls("gpt", { mode: "agent", parameters: GPT }, { params: { thinking: "xhigh" } });
expect(r.modelSelection.params).toEqual([{ id: "reasoning", value: "extra-high" }]);
});

it("leaves an unmappable value in place and warns with the accepted values", () => {
const r = resolveControls("grok-4.6", { mode: "agent", parameters: GROK_4_6 }, { params: { thinking: "max" } });
expect(r.modelSelection.params).toEqual([{ id: "thinking", value: "max" }]);
expect(r.warnings).toEqual([expect.stringContaining("effort=low|medium|high|xhigh")]);
});

it("changes nothing when the model's parameters are unknown", () => {
const r = resolveControls("grok-4.6", { mode: "agent" }, { params: { thinking: "medium" } });
expect(r.modelSelection.params).toEqual([{ id: "thinking", value: "medium" }]);
expect(r.warnings).toEqual([]);
});
});

// buildModelVariants behavior is covered in test/model-variants.test.ts.
43 changes: 42 additions & 1 deletion test/language-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtempSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -419,6 +419,47 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => {
});
});

it("sends a configured thinking level as the cataloged model's effort param (#119)", async () => {
const dir = join(process.env.XDG_CACHE_HOME!, "opencode-cursor");
mkdirSync(dir, { recursive: true });
const param = (id: string, values: string[]) => ({ id, displayName: id, values: values.map((value) => ({ value })) });
writeFileSync(
join(dir, "models-latest.json"),
JSON.stringify({
savedAt: Date.now(),
models: [
{
id: "grok-4.6",
displayName: "Grok 4.6",
parameters: [param("effort", ["low", "medium", "high", "xhigh"]), param("fast", ["false", "true"])],
},
],
}),
);
const model = new CursorLanguageModel("grok-4.6", { providerName: "cursor", cwd: "/tmp", mode: "agent" });
create.mockResolvedValueOnce(fakeAgent({ agentId: "a1" }));

await collectStream(
streamCall(model, {
prompt: [sys("S"), user("hi")],
providerOptions: { cursor: { sessionID: "s1", params: { thinking: "medium", fast: "false" } } },
} as never),
);
// Don't leak this catalog into later tests' fallback lookups.
rmSync(join(dir, "models-latest.json"));

const acquireArgs = create.mock.calls[0]?.[0] as {
model: { id: string; params?: Array<{ id: string; value: string }> };
};
expect(acquireArgs.model).toEqual({
id: "grok-4.6",
params: [
{ id: "fast", value: "false" },
{ id: "effort", value: "medium" },
],
});
});

it("chains the original resume failure as cause when re-acquire throws", async () => {
const model = makeModel();

Expand Down
Loading