diff --git a/CHANGELOG.md b/CHANGELOG.md index 348c47d..4045944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4b37700..3ffe854 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/model-variants.ts b/src/model-variants.ts index a092383..1e92b48 100644 --- a/src/model-variants.ts +++ b/src/model-variants.ts @@ -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[number]): string[] { +export function paramValues(param: NonNullable[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)); } diff --git a/src/provider/controls.ts b/src/provider/controls.ts index 95d9a96..9ff7b5e 100644 --- a/src/provider/controls.ts +++ b/src/provider/controls.ts @@ -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; /** Per-model static control defaults (from provider/model config options). */ export interface StaticControls { @@ -11,11 +14,15 @@ export interface StaticControls { * `{ fast: "false" }`, when a turn arrives with no params of its own. */ defaults?: Record; + /** 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[]; } /** @@ -47,7 +54,10 @@ function isMode(value: unknown): value is AgentModeOption { * Recognized keys in `providerOptions.cursor`: * - `mode`: "agent" | "plan" * - `params`: Record 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, @@ -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, + 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.`]; } diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index be1d7aa..36c94d3 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -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, @@ -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) { @@ -158,15 +172,17 @@ export class CursorLanguageModel implements LanguageModelV3 { const providerOptions = options.providerOptions?.[this.provider] as | Record | 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, diff --git a/test/controls.test.ts b/test/controls.test.ts index 2774401..959fee9 100644 --- a/test/controls.test.ts +++ b/test/controls.test.ts @@ -71,4 +71,83 @@ describe("resolveControls", () => { }); }); +type Params = NonNullable; +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. diff --git a/test/language-model.test.ts b/test/language-model.test.ts index 6101f95..895b696 100644 --- a/test/language-model.test.ts +++ b/test/language-model.test.ts @@ -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"; @@ -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();