cursor-opencode-provider 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -4
- package/dist/language-model.js +21 -7
- package/dist/models.d.ts +29 -4
- package/dist/models.js +134 -17
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +128 -41
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/transport/connect.js +11 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Pin a version if you want: `"cursor-opencode-provider@0.1
|
|
50
|
+
Pin a version if you want: `"cursor-opencode-provider@0.2.1"`.
|
|
51
51
|
|
|
52
52
|
### From a local clone
|
|
53
53
|
|
|
@@ -102,7 +102,7 @@ Choose the **cursor** provider, then one of:
|
|
|
102
102
|
| **Cursor account (browser login)** | PKCE OAuth — opens cursor.com to sign in |
|
|
103
103
|
| **API key** | Paste a key from [cursor.com/settings](https://cursor.com/settings) (`sk-...`) |
|
|
104
104
|
|
|
105
|
-
After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups,
|
|
105
|
+
After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups, a missing, empty, expired, or old-schema cache is refreshed during config load when Cursor auth is available; an existing stale cache remains usable if refresh fails.
|
|
106
106
|
|
|
107
107
|
### Paths (XDG)
|
|
108
108
|
|
|
@@ -114,12 +114,37 @@ After login, the plugin fetches your available models and writes them to `~/.cac
|
|
|
114
114
|
|
|
115
115
|
### Select a model
|
|
116
116
|
|
|
117
|
-
Pick a model from the cached list (for example `composer-2.5`, `default`, or a Claude/GPT
|
|
117
|
+
Pick a model from the cached list (for example `composer-2.5`, `default`, or a Claude/GPT model exposed by your subscription):
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
120
|
opencode run --model cursor/composer-2.5 "Hello from Cursor via OpenCode"
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
#### Variants
|
|
124
|
+
|
|
125
|
+
Cursor models often expose parameterized variants (effort, thinking, fast, context tier, …). The plugin materializes those as OpenCode **model variants**. In the TUI, pick one from the variant dialog or cycle with OpenCode’s `variant_cycle` keybind (default `ctrl+t`).
|
|
126
|
+
|
|
127
|
+
The selected variant’s Cursor parameter map is forwarded on the Run as `requested_model.parameters` (isolated under `providerOptions.cursor.cursorVariantParameters` so unrelated OpenCode options are not leaked onto the wire).
|
|
128
|
+
|
|
129
|
+
#### 1M / long context
|
|
130
|
+
|
|
131
|
+
OpenCode’s context limit is static per model entry, while Cursor’s long-context tier is a variant parameter (`context=1m`). When a model has both a base tier and a `1m` tier, the plugin emits a separate OpenCode entry `<model-id>-1m` (for example `claude-opus-4-8-1m`) with:
|
|
132
|
+
|
|
133
|
+
- `limit.context` set to the 1M window (so overflow checks and compaction match the tier)
|
|
134
|
+
- only the long-context variants in its picker
|
|
135
|
+
- the real Cursor model id carried in `options.cursorModelId` (not `config.id`, which would make OpenCode merge base variants into the 1M entry)
|
|
136
|
+
|
|
137
|
+
The Run still uses Cursor’s original model id; OpenCode’s synthetic `-1m` id is only for picking and limits.
|
|
138
|
+
|
|
139
|
+
#### Max mode
|
|
140
|
+
|
|
141
|
+
Cursor IDE has a separate **Max Mode** toggle that sets `requested_model.max_mode` and selects the default max / 1m variant. OpenCode has no equivalent custom toggle, so this provider approximates it:
|
|
142
|
+
|
|
143
|
+
- Selecting a `*-1m` model (or any resolved params with `context=1m`) sets wire `max_mode` to `true`
|
|
144
|
+
- An explicit `providerOptions.cursor.maxMode` hint also turns it on
|
|
145
|
+
|
|
146
|
+
There is no independent Max Mode chrome in OpenCode beyond choosing the 1M model / long-context variant.
|
|
147
|
+
|
|
123
148
|
## Programmatic usage
|
|
124
149
|
|
|
125
150
|
```ts
|
|
@@ -219,4 +244,4 @@ OpenCode
|
|
|
219
244
|
|
|
220
245
|
## License
|
|
221
246
|
|
|
222
|
-
MIT
|
|
247
|
+
MIT
|
package/dist/language-model.js
CHANGED
|
@@ -10,7 +10,7 @@ import { handleKvServerMessage } from "./protocol/kv.js";
|
|
|
10
10
|
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
11
11
|
import { conversationBlobCount } from "./protocol/blob-store.js";
|
|
12
12
|
import { sessionManager } from "./session.js";
|
|
13
|
-
import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
|
|
13
|
+
import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
14
14
|
import { buildRequestContext } from "./context/build.js";
|
|
15
15
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
16
16
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
@@ -184,10 +184,24 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
184
184
|
telemetryEnabled: resolveTelemetryEnabled(options),
|
|
185
185
|
}));
|
|
186
186
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
187
|
+
// OpenCode merges model, agent, and selected-variant options before placing
|
|
188
|
+
// them under providerOptions.cursor. Read only the plugin's dedicated nested
|
|
189
|
+
// payload so unrelated options never become requested_model.parameters.
|
|
190
|
+
const picked = extractCursorVariantParameters(providerOptions);
|
|
191
|
+
const cursorModelId = resolveCursorWireModelId(providerOptions, modelId);
|
|
192
|
+
const reasoningEffort = typeof providerOptions?.reasoningEffort === "string"
|
|
193
|
+
? providerOptions.reasoningEffort
|
|
194
|
+
: undefined;
|
|
195
|
+
const hintMaxMode = !!(providerOptions?.maxMode ?? false);
|
|
196
|
+
const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
|
|
197
|
+
const parameterValues = resolveVariantParameters(modelInfo, {
|
|
198
|
+
reasoningEffort,
|
|
199
|
+
maxMode: hintMaxMode,
|
|
200
|
+
picked,
|
|
201
|
+
});
|
|
202
|
+
// Wire max_mode from the hint *or* a 1m context pick — OpenCode's variant
|
|
203
|
+
// paramMap does not include a maxMode key when the user selects 1m.
|
|
204
|
+
const maxMode = hintMaxMode || paramsImplyMaxMode(parameterValues);
|
|
191
205
|
// Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
|
|
192
206
|
// that signal when a turn ends with tool-calls; the Cursor stream must stay
|
|
193
207
|
// open until we write the exec results on the next doStream.
|
|
@@ -208,7 +222,7 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
208
222
|
const conversationState = getCheckpoint(conversationId);
|
|
209
223
|
const reqBytes = buildRunRequest({
|
|
210
224
|
text: userText,
|
|
211
|
-
modelId,
|
|
225
|
+
modelId: cursorModelId,
|
|
212
226
|
conversationId,
|
|
213
227
|
systemPrompt: conversationState ? undefined : systemPrompt,
|
|
214
228
|
conversationState,
|
|
@@ -227,7 +241,7 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
227
241
|
const hooksCtx = typeof requestContext.hooks_additional_context === "string"
|
|
228
242
|
? requestContext.hooks_additional_context
|
|
229
243
|
: "";
|
|
230
|
-
trace(`outbound Run: model=${modelId} conversationId=${conversationId} ` +
|
|
244
|
+
trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
|
|
231
245
|
`params=${JSON.stringify(parameterValues ?? [])} ` +
|
|
232
246
|
`maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} tools=${tools.length} ` +
|
|
233
247
|
`skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
|
package/dist/models.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ export type ModelParameterValue = {
|
|
|
2
2
|
id: string;
|
|
3
3
|
value: string;
|
|
4
4
|
};
|
|
5
|
+
/** Dedicated OpenCode provider-option key for a selected Cursor variant. */
|
|
6
|
+
export declare const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
|
|
7
|
+
/** Real Cursor model id used when an OpenCode entry has a synthetic id. */
|
|
8
|
+
export declare const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
|
|
5
9
|
export type ModelVariant = {
|
|
6
10
|
key: string;
|
|
7
11
|
parameterValues: ModelParameterValue[];
|
|
@@ -16,13 +20,27 @@ export type ModelInfo = {
|
|
|
16
20
|
supportsThinking?: boolean;
|
|
17
21
|
supportsAgent?: boolean;
|
|
18
22
|
maxContext?: number;
|
|
23
|
+
/** Context window when max-mode is on (proto field 16). */
|
|
24
|
+
maxContextForMaxMode?: number;
|
|
19
25
|
supportsMaxMode?: boolean;
|
|
20
26
|
variants: ModelVariant[];
|
|
21
27
|
};
|
|
22
28
|
export type ModelCache = {
|
|
23
29
|
models: ModelInfo[];
|
|
24
30
|
fetchedAt: number;
|
|
31
|
+
/** Absent or mismatched → treat cache as stale (forces AvailableModels refetch). */
|
|
32
|
+
schemaVersion?: number;
|
|
25
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Read only the dedicated variant payload generated by the plugin. OpenCode
|
|
36
|
+
* merges model, agent, and variant options into one providerOptions namespace;
|
|
37
|
+
* treating that whole object as Cursor parameters would leak unrelated options
|
|
38
|
+
* onto the wire.
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractCursorVariantParameters(providerOptions: Record<string, unknown> | undefined): ModelParameterValue[] | undefined;
|
|
41
|
+
export declare function resolveCursorWireModelId(providerOptions: Record<string, unknown> | undefined, fallback: string): string;
|
|
42
|
+
/** True when resolved params select the long-context (1m) tier. */
|
|
43
|
+
export declare function paramsImplyMaxMode(params: ModelParameterValue[]): boolean;
|
|
26
44
|
export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
|
|
27
45
|
export declare function cacheFilePath(cacheDir: string): string;
|
|
28
46
|
export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
|
|
@@ -30,14 +48,21 @@ export declare function writeCache(cacheDir: string, cache: ModelCache): Promise
|
|
|
30
48
|
export declare function mapAvailableModelsResponse(raw: Record<string, unknown>): ModelInfo[];
|
|
31
49
|
/**
|
|
32
50
|
* Resolve the parameter values to send in `requested_model.parameters` for a
|
|
33
|
-
* given model +
|
|
34
|
-
* full `{id,value}` set (per-model vocabulary
|
|
35
|
-
*
|
|
36
|
-
* hand.
|
|
51
|
+
* given model + the variant opencode selected. Each variant already carries
|
|
52
|
+
* the full `{id,value}` set (per-model vocabulary — effort, fast, thinking,
|
|
53
|
+
* context, …), so we pick the matching variant and return its parameters
|
|
54
|
+
* verbatim — the client never constructs these by hand.
|
|
55
|
+
*
|
|
56
|
+
* `picked` is the user-selected variant paramMap (opencode sends it under
|
|
57
|
+
* `providerOptions.cursor`); when present it wins over effort/maxMode hints
|
|
58
|
+
* so every param the user chose (context, fast, …) is forwarded to Cursor.
|
|
59
|
+
* Hints (`reasoningEffort`, `maxMode`) live beside the dedicated picked array,
|
|
60
|
+
* so the picked values are already isolated and can be matched verbatim.
|
|
37
61
|
*/
|
|
38
62
|
export declare function resolveVariantParameters(model: ModelInfo | undefined, opts?: {
|
|
39
63
|
reasoningEffort?: string;
|
|
40
64
|
maxMode?: boolean;
|
|
65
|
+
picked?: ModelParameterValue[];
|
|
41
66
|
}): ModelParameterValue[];
|
|
42
67
|
export declare function fetchModels(token: string, options?: {
|
|
43
68
|
baseURL?: string;
|
package/dist/models.js
CHANGED
|
@@ -1,11 +1,69 @@
|
|
|
1
|
-
import { MODEL_CACHE_FILE, MODEL_CACHE_TTL_MS } from "./shared.js";
|
|
1
|
+
import { MODEL_CACHE_FILE, MODEL_CACHE_SCHEMA_VERSION, MODEL_CACHE_TTL_MS } from "./shared.js";
|
|
2
2
|
import { unaryAvailableModels } from "./transport/connect.js";
|
|
3
3
|
import { buildRequestedModelParams } from "./protocol/thinking.js";
|
|
4
4
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
/** Dedicated OpenCode provider-option key for a selected Cursor variant. */
|
|
8
|
+
export const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
|
|
9
|
+
/** Real Cursor model id used when an OpenCode entry has a synthetic id. */
|
|
10
|
+
export const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
|
|
11
|
+
/**
|
|
12
|
+
* Read only the dedicated variant payload generated by the plugin. OpenCode
|
|
13
|
+
* merges model, agent, and variant options into one providerOptions namespace;
|
|
14
|
+
* treating that whole object as Cursor parameters would leak unrelated options
|
|
15
|
+
* onto the wire.
|
|
16
|
+
*/
|
|
17
|
+
export function extractCursorVariantParameters(providerOptions) {
|
|
18
|
+
const raw = providerOptions?.[CURSOR_VARIANT_PARAMETERS_KEY];
|
|
19
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const params = [];
|
|
22
|
+
for (const item of raw) {
|
|
23
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
24
|
+
return undefined;
|
|
25
|
+
const { id, value } = item;
|
|
26
|
+
if (typeof id !== "string" || typeof value !== "string")
|
|
27
|
+
return undefined;
|
|
28
|
+
params.push({ id, value });
|
|
29
|
+
}
|
|
30
|
+
return params;
|
|
31
|
+
}
|
|
32
|
+
export function resolveCursorWireModelId(providerOptions, fallback) {
|
|
33
|
+
const value = providerOptions?.[CURSOR_WIRE_MODEL_ID_KEY];
|
|
34
|
+
return typeof value === "string" && value.trim() ? value : fallback;
|
|
35
|
+
}
|
|
36
|
+
/** True when resolved params select the long-context (1m) tier. */
|
|
37
|
+
export function paramsImplyMaxMode(params) {
|
|
38
|
+
return params.some((p) => p.id === "context" && p.value === "1m");
|
|
39
|
+
}
|
|
40
|
+
// Cursor encodes a model's context window as a variant parameter `id: "context"`
|
|
41
|
+
// whose value is a tier string — the same 200k / 272k / 300k / 1m the IDE's
|
|
42
|
+
// picker shows. The base tier rides on the default non-max variant; the 1M
|
|
43
|
+
// tier (when the model supports max mode) rides on the default max variant.
|
|
44
|
+
// `context_token_limit` (#15) / `context_token_limit_for_max_mode` (#16) are
|
|
45
|
+
// also defined on AvailableModel but the server often leaves them empty — the
|
|
46
|
+
// variant param is the primary source (request flags may still populate them).
|
|
47
|
+
const CONTEXT_TIER_TO_TOKENS = {
|
|
48
|
+
"200k": 200_000,
|
|
49
|
+
"272k": 272_000,
|
|
50
|
+
"300k": 300_000,
|
|
51
|
+
"1m": 1_000_000,
|
|
52
|
+
};
|
|
53
|
+
function variantContextTokens(v) {
|
|
54
|
+
const raw = v?.parameterValues.find((p) => p.id === "context")?.value;
|
|
55
|
+
if (!raw)
|
|
56
|
+
return undefined;
|
|
57
|
+
const mapped = CONTEXT_TIER_TO_TOKENS[raw];
|
|
58
|
+
if (mapped !== undefined)
|
|
59
|
+
return mapped;
|
|
60
|
+
const n = Number(raw);
|
|
61
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
62
|
+
}
|
|
7
63
|
// ── Cache helpers ──
|
|
8
64
|
export function isCacheFresh(cache, ttlMs = MODEL_CACHE_TTL_MS) {
|
|
65
|
+
if (cache.schemaVersion !== MODEL_CACHE_SCHEMA_VERSION)
|
|
66
|
+
return false;
|
|
9
67
|
return Date.now() - cache.fetchedAt < ttlMs;
|
|
10
68
|
}
|
|
11
69
|
export function cacheFilePath(cacheDir) {
|
|
@@ -53,12 +111,22 @@ export function mapAvailableModelsResponse(raw) {
|
|
|
53
111
|
isDefaultMax: !!(v.isDefaultMaxConfig ?? v.is_default_max_config ?? false),
|
|
54
112
|
});
|
|
55
113
|
}
|
|
114
|
+
// Derive the context window from the variant `context` param (primary
|
|
115
|
+
// source). Base tier ← default non-max variant; max tier ← default max
|
|
116
|
+
// variant. Fall back to the (often empty) proto fields, then undefined.
|
|
117
|
+
// Prefer a non-1m context when isDefaultNonMax is missing so a leading
|
|
118
|
+
// max-mode variant cannot inflate the base window.
|
|
119
|
+
const variantBaseContext = variantContextTokens(variants.find((v) => v.isDefaultNonMax)) ??
|
|
120
|
+
variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context" && p.value !== "1m"))) ??
|
|
121
|
+
variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
|
|
122
|
+
const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
|
|
56
123
|
models.push({
|
|
57
124
|
id: name,
|
|
58
125
|
displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
|
|
59
126
|
supportsThinking: !!(e.supportsThinking ?? e.supports_thinking ?? false),
|
|
60
127
|
supportsAgent: !!(e.supportsAgent ?? e.supports_agent ?? false),
|
|
61
|
-
maxContext: (e.contextTokenLimit ?? e.context_token_limit ?? undefined),
|
|
128
|
+
maxContext: (variantBaseContext ?? e.contextTokenLimit ?? e.context_token_limit ?? undefined),
|
|
129
|
+
maxContextForMaxMode: (variantMaxContext ?? e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode ?? undefined),
|
|
62
130
|
supportsMaxMode: !!(e.supportsMaxMode ?? e.supports_max_mode ?? false),
|
|
63
131
|
variants,
|
|
64
132
|
});
|
|
@@ -68,23 +136,54 @@ export function mapAvailableModelsResponse(raw) {
|
|
|
68
136
|
// ── Variant resolution ──
|
|
69
137
|
/**
|
|
70
138
|
* Resolve the parameter values to send in `requested_model.parameters` for a
|
|
71
|
-
* given model +
|
|
72
|
-
* full `{id,value}` set (per-model vocabulary
|
|
73
|
-
*
|
|
74
|
-
* hand.
|
|
139
|
+
* given model + the variant opencode selected. Each variant already carries
|
|
140
|
+
* the full `{id,value}` set (per-model vocabulary — effort, fast, thinking,
|
|
141
|
+
* context, …), so we pick the matching variant and return its parameters
|
|
142
|
+
* verbatim — the client never constructs these by hand.
|
|
143
|
+
*
|
|
144
|
+
* `picked` is the user-selected variant paramMap (opencode sends it under
|
|
145
|
+
* `providerOptions.cursor`); when present it wins over effort/maxMode hints
|
|
146
|
+
* so every param the user chose (context, fast, …) is forwarded to Cursor.
|
|
147
|
+
* Hints (`reasoningEffort`, `maxMode`) live beside the dedicated picked array,
|
|
148
|
+
* so the picked values are already isolated and can be matched verbatim.
|
|
75
149
|
*/
|
|
76
150
|
export function resolveVariantParameters(model, opts = {}) {
|
|
151
|
+
const picked = opts.picked?.length ? opts.picked.map((p) => ({ ...p })) : undefined;
|
|
77
152
|
if (!model || model.variants.length === 0) {
|
|
78
|
-
|
|
153
|
+
if (picked?.length)
|
|
154
|
+
return picked;
|
|
79
155
|
return opts.reasoningEffort ? [{ id: "effort", value: opts.reasoningEffort }] : [];
|
|
80
156
|
}
|
|
81
157
|
const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning")?.value;
|
|
82
158
|
const isFast = (v) => v.parameterValues.find((p) => p.id === "fast")?.value === "true";
|
|
159
|
+
const contextOf = (v) => v.parameterValues.find((p) => p.id === "context")?.value;
|
|
160
|
+
const isMaxVariant = (v) => v.isDefaultMax || contextOf(v) === "1m";
|
|
83
161
|
const wantMax = opts.maxMode ?? false;
|
|
84
|
-
|
|
85
|
-
|
|
162
|
+
// Non-fast pool for hint-based resolution only. Exact `picked` matching
|
|
163
|
+
// searches all variants so Fast selections are not silently dropped.
|
|
164
|
+
const pool = model.variants.filter((v) => !isFast(v));
|
|
165
|
+
const scoped = pool.length > 0 ? pool : model.variants;
|
|
166
|
+
// 1. Variant explicitly picked by opencode (verbatim — preserves context, fast, …).
|
|
167
|
+
if (picked?.length) {
|
|
168
|
+
const signature = new Set(picked.map((p) => `${p.id}=${p.value}`));
|
|
169
|
+
const exact = model.variants.find((v) => v.parameterValues.every((p) => signature.has(`${p.id}=${p.value}`)) &&
|
|
170
|
+
signature.size === v.parameterValues.length);
|
|
171
|
+
if (exact) {
|
|
172
|
+
return buildRequestedModelParams(exact.parameterValues, {
|
|
173
|
+
reasoningEffort: opts.reasoningEffort,
|
|
174
|
+
maxMode: wantMax,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Unmatched: forward the stripped pick so the TUI choice is not discarded.
|
|
178
|
+
return picked;
|
|
179
|
+
}
|
|
180
|
+
// When maxMode is requested, prefer max-tier variants for effort matching
|
|
181
|
+
// so "high + max" lands on 1m high rather than the first 300k high.
|
|
182
|
+
const maxScoped = wantMax ? scoped.filter(isMaxVariant) : [];
|
|
183
|
+
const effortPool = maxScoped.length > 0 ? maxScoped : scoped;
|
|
184
|
+
// 2. Effort hint (e.g. user passed reasoningEffort through the CLI).
|
|
86
185
|
if (opts.reasoningEffort) {
|
|
87
|
-
const match =
|
|
186
|
+
const match = effortPool.find((v) => effortOf(v) === opts.reasoningEffort);
|
|
88
187
|
if (match) {
|
|
89
188
|
return buildRequestedModelParams(match.parameterValues, {
|
|
90
189
|
reasoningEffort: opts.reasoningEffort,
|
|
@@ -92,11 +191,17 @@ export function resolveVariantParameters(model, opts = {}) {
|
|
|
92
191
|
});
|
|
93
192
|
}
|
|
94
193
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
194
|
+
// 3. Max-mode hint → prefer the default max variant (1m context).
|
|
195
|
+
if (wantMax) {
|
|
196
|
+
const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find((v) => contextOf(v) === "1m");
|
|
197
|
+
if (max)
|
|
198
|
+
return buildRequestedModelParams(max.parameterValues, { maxMode: true });
|
|
199
|
+
}
|
|
200
|
+
// 4. Default non-max variant.
|
|
201
|
+
const byDefault = scoped.find((v) => v.isDefaultNonMax) ?? scoped[0];
|
|
202
|
+
return buildRequestedModelParams(byDefault.parameterValues, {
|
|
98
203
|
reasoningEffort: opts.reasoningEffort,
|
|
99
|
-
maxMode:
|
|
204
|
+
maxMode: false,
|
|
100
205
|
});
|
|
101
206
|
}
|
|
102
207
|
// ── Fetch + cache orchestration ──
|
|
@@ -110,7 +215,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
110
215
|
if (cached && isCacheFresh(cached)) {
|
|
111
216
|
// Background refresh (fire and forget)
|
|
112
217
|
fetchModels(token, options)
|
|
113
|
-
.then((models) => writeCache(cacheDir, {
|
|
218
|
+
.then((models) => writeCache(cacheDir, {
|
|
219
|
+
models,
|
|
220
|
+
fetchedAt: Date.now(),
|
|
221
|
+
schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
|
|
222
|
+
}))
|
|
114
223
|
.catch(() => {
|
|
115
224
|
/* background refresh failure is non-fatal */
|
|
116
225
|
});
|
|
@@ -120,7 +229,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
120
229
|
if (cached) {
|
|
121
230
|
try {
|
|
122
231
|
const models = await fetchModels(token, options);
|
|
123
|
-
const newCache = {
|
|
232
|
+
const newCache = {
|
|
233
|
+
models,
|
|
234
|
+
fetchedAt: Date.now(),
|
|
235
|
+
schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
|
|
236
|
+
};
|
|
124
237
|
await writeCache(cacheDir, newCache);
|
|
125
238
|
return models;
|
|
126
239
|
}
|
|
@@ -130,7 +243,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
130
243
|
}
|
|
131
244
|
// No cache → must fetch
|
|
132
245
|
const models = await fetchModels(token, options);
|
|
133
|
-
const newCache = {
|
|
246
|
+
const newCache = {
|
|
247
|
+
models,
|
|
248
|
+
fetchedAt: Date.now(),
|
|
249
|
+
schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
|
|
250
|
+
};
|
|
134
251
|
await writeCache(cacheDir, newCache);
|
|
135
252
|
return models;
|
|
136
253
|
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ import { type ModelInfo } from "./models.js";
|
|
|
11
11
|
export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
|
|
12
12
|
export declare function modelInfoToConfig(mi: ModelInfo, options?: {
|
|
13
13
|
thinkingSuffix?: boolean;
|
|
14
|
+
contextTier?: "base" | "long";
|
|
14
15
|
}): Record<string, any>;
|
|
16
|
+
export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
|
|
15
17
|
export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
|
package/dist/plugin.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import { CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
|
|
2
2
|
import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
|
|
3
|
-
import { readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
3
|
+
import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
4
4
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
5
5
|
import { readStoredAuth } from "./context/auth-store.js";
|
|
6
6
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
7
7
|
const MODULE_URL = new URL("./index.js", import.meta.url).href;
|
|
8
8
|
/**
|
|
9
|
-
* Strip characters that break rendering in the OpenCode
|
|
10
|
-
* model or variant display name:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Strip characters and markup that break rendering in the OpenCode TUI/GUI from
|
|
10
|
+
* a model or variant display name:
|
|
11
|
+
* • HTML tags (e.g. `<span style="…">Medium</span>`) — the IDE colours variant
|
|
12
|
+
* suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
|
|
13
|
+
* would show as literal text. We drop the tags and keep the inner text
|
|
14
|
+
* ("Medium").
|
|
15
|
+
* • HTML/markup chars (`< > & " ' \``), parentheses.
|
|
16
|
+
* • Tabs/newlines collapse to single spaces.
|
|
17
|
+
* Dots and unicode letters are preserved so names stay readable.
|
|
18
|
+
* Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
|
|
13
19
|
*/
|
|
14
20
|
function safeLabel(value) {
|
|
15
21
|
return (value
|
|
22
|
+
.replace(/<[^>]+>/g, "")
|
|
16
23
|
.replace(/[()<>&"'`]/g, "")
|
|
17
24
|
.replace(/\s+/g, " ")
|
|
18
25
|
.trim() || "default");
|
|
@@ -20,26 +27,69 @@ function safeLabel(value) {
|
|
|
20
27
|
function baseName(mi) {
|
|
21
28
|
return safeLabel(mi.displayName ?? mi.id);
|
|
22
29
|
}
|
|
23
|
-
function modelInfoVariants(mi) {
|
|
24
|
-
if (
|
|
30
|
+
function modelInfoVariants(mi, variants) {
|
|
31
|
+
if (variants.length === 0)
|
|
25
32
|
return undefined;
|
|
26
33
|
const entries = {};
|
|
27
34
|
const usedKeys = new Set();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const baseName = safeLabel(mi.displayName ?? mi.id);
|
|
36
|
+
// Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
|
|
37
|
+
// "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
|
|
38
|
+
// sees in Cursor. Two variants can sanitize to the same name when the IDE
|
|
39
|
+
// wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
|
|
40
|
+
// suffix collapses to the bare model name after stripping). To guarantee
|
|
41
|
+
// every variant stays pickable:
|
|
42
|
+
// 1. If the sanitized displayName matches the model name itself, suffix
|
|
43
|
+
// it with distinguishing params (or "default") so it never collides
|
|
44
|
+
// with the model entry in the variant panel.
|
|
45
|
+
// 2. If two variants still collide, tag the later one with its params.
|
|
46
|
+
// Suffixes must stay free of `()` / markup chars — same constraint as
|
|
47
|
+
// safeLabel (issue #2); use spaced tokens, not parenthetical tags.
|
|
48
|
+
const tagDims = (p) => {
|
|
49
|
+
const labels = [];
|
|
50
|
+
for (const d of p) {
|
|
51
|
+
if (d.id === "fast" && d.value === "true")
|
|
52
|
+
labels.push("Fast");
|
|
53
|
+
else if (d.id === "thinking" && d.value === "true")
|
|
54
|
+
labels.push("Thinking");
|
|
55
|
+
else if (d.id === "context")
|
|
56
|
+
labels.push(d.value);
|
|
57
|
+
}
|
|
58
|
+
if (labels.length > 0)
|
|
59
|
+
return ` ${labels.join(" ")}`;
|
|
60
|
+
// No params at all — still disambiguate from the model name itself.
|
|
61
|
+
if (p.length === 0)
|
|
62
|
+
return "";
|
|
63
|
+
return " default";
|
|
64
|
+
};
|
|
65
|
+
for (const v of variants) {
|
|
66
|
+
const sanitized = safeLabel(v.displayName || v.key || "default");
|
|
67
|
+
let key = sanitized;
|
|
68
|
+
// Never let a variant key equal the model name — that would make the
|
|
69
|
+
// variant entry indistinguishable from the model entry in pickers that
|
|
70
|
+
// collapse them.
|
|
71
|
+
if (key === baseName && !usedKeys.has(key)) {
|
|
72
|
+
key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
|
|
73
|
+
}
|
|
74
|
+
else if (usedKeys.has(key)) {
|
|
75
|
+
key = `${sanitized}${tagDims(v.parameterValues)}`;
|
|
76
|
+
}
|
|
77
|
+
let n = 2;
|
|
32
78
|
while (usedKeys.has(key))
|
|
33
|
-
key = `${
|
|
79
|
+
key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
|
|
34
80
|
usedKeys.add(key);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
entries[key] = params;
|
|
81
|
+
entries[key] = {
|
|
82
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
|
|
83
|
+
};
|
|
40
84
|
}
|
|
41
85
|
return entries;
|
|
42
86
|
}
|
|
87
|
+
function isLongContextVariant(v) {
|
|
88
|
+
return v.parameterValues.some((p) => p.id === "context" && p.value === "1m");
|
|
89
|
+
}
|
|
90
|
+
function variantsForTier(mi, tier) {
|
|
91
|
+
return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
|
|
92
|
+
}
|
|
43
93
|
/**
|
|
44
94
|
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
45
95
|
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
@@ -66,31 +116,66 @@ export function thinkingSuffixBaseNames(models) {
|
|
|
66
116
|
return ambiguous;
|
|
67
117
|
}
|
|
68
118
|
export function modelInfoToConfig(mi, options = {}) {
|
|
119
|
+
const contextTier = options.contextTier ?? "base";
|
|
120
|
+
const variants = variantsForTier(mi, contextTier);
|
|
69
121
|
let name = baseName(mi);
|
|
70
122
|
if (options.thinkingSuffix)
|
|
71
123
|
name += " Thinking";
|
|
124
|
+
if (contextTier === "long")
|
|
125
|
+
name += " 1M";
|
|
126
|
+
// OpenCode's context limit is static per model entry, while Cursor's context
|
|
127
|
+
// tier is a variant parameter. Long-context choices are therefore emitted as
|
|
128
|
+
// separate OpenCode entries by modelsToConfig.
|
|
129
|
+
const context = contextTier === "long"
|
|
130
|
+
? (mi.maxContextForMaxMode ?? 1_000_000)
|
|
131
|
+
: (mi.maxContext ?? 200_000);
|
|
72
132
|
const config = {
|
|
73
133
|
name,
|
|
74
134
|
reasoning: mi.supportsThinking ?? false,
|
|
75
135
|
tool_call: mi.supportsAgent ?? true,
|
|
76
136
|
temperature: false,
|
|
77
137
|
limit: {
|
|
78
|
-
context
|
|
138
|
+
context,
|
|
79
139
|
output: 4096,
|
|
80
140
|
},
|
|
81
141
|
};
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
config.variants =
|
|
142
|
+
const variantConfig = modelInfoVariants(mi, variants);
|
|
143
|
+
if (variantConfig)
|
|
144
|
+
config.variants = variantConfig;
|
|
145
|
+
if (contextTier === "long") {
|
|
146
|
+
const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
|
|
147
|
+
if (defaultVariant) {
|
|
148
|
+
config.options = {
|
|
149
|
+
[CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
|
|
150
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
85
154
|
return config;
|
|
86
155
|
}
|
|
87
|
-
function modelsToConfig(models) {
|
|
156
|
+
export function modelsToConfig(models) {
|
|
88
157
|
const ambiguous = thinkingSuffixBaseNames(models);
|
|
89
158
|
const out = {};
|
|
159
|
+
const usedIds = new Set(models.map((m) => m.id));
|
|
90
160
|
for (const m of models) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
161
|
+
const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
|
|
162
|
+
const baseVariants = variantsForTier(m, "base");
|
|
163
|
+
const longVariants = variantsForTier(m, "long");
|
|
164
|
+
if (baseVariants.length > 0 || longVariants.length === 0) {
|
|
165
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
|
|
166
|
+
}
|
|
167
|
+
if (longVariants.length === 0)
|
|
168
|
+
continue;
|
|
169
|
+
if (baseVariants.length === 0) {
|
|
170
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let longId = `${m.id}-1m`;
|
|
174
|
+
let suffix = 2;
|
|
175
|
+
while (usedIds.has(longId))
|
|
176
|
+
longId = `${m.id}-1m-${suffix++}`;
|
|
177
|
+
usedIds.add(longId);
|
|
178
|
+
out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
94
179
|
}
|
|
95
180
|
return out;
|
|
96
181
|
}
|
|
@@ -198,25 +283,27 @@ export async function CursorPlugin(input) {
|
|
|
198
283
|
}
|
|
199
284
|
async function loadModels() {
|
|
200
285
|
const cached = await readCache(cacheDir);
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
286
|
+
if (cached?.models.length && isCacheFresh(cached)) {
|
|
287
|
+
return modelsToConfig(cached.models);
|
|
288
|
+
}
|
|
289
|
+
// Config runs before auth.loader and has no getAuth(); read the durable
|
|
290
|
+
// store (normally the same source getAuth() uses). Refresh missing, expired,
|
|
291
|
+
// or old-schema caches here so this process materializes the new model set.
|
|
292
|
+
const auth = await authFromStore();
|
|
293
|
+
if (auth) {
|
|
294
|
+
const accessToken = await resolveAccessToken(auth);
|
|
295
|
+
if (accessToken) {
|
|
296
|
+
try {
|
|
297
|
+
const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
|
|
298
|
+
return modelsToConfig(models);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// No usable cache and discovery failed — leave the list empty.
|
|
215
302
|
}
|
|
216
303
|
}
|
|
217
|
-
return {};
|
|
218
304
|
}
|
|
219
|
-
|
|
305
|
+
// Preserve stale-on-failure/offline behavior for an existing cache.
|
|
306
|
+
return cached?.models.length ? modelsToConfig(cached.models) : {};
|
|
220
307
|
}
|
|
221
308
|
return {
|
|
222
309
|
async config(cfg) {
|
package/dist/shared.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export declare const RUN_PATH = "/agent.v1.AgentService/Run";
|
|
|
7
7
|
export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
|
|
8
8
|
export declare const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
|
|
9
9
|
export declare const MODEL_CACHE_FILE = "cursor-models.json";
|
|
10
|
+
/** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
|
|
11
|
+
export declare const MODEL_CACHE_SCHEMA_VERSION = 2;
|
|
10
12
|
export declare const MODEL_CACHE_TTL_MS = 86400000;
|
|
11
13
|
export declare const VERSION_CACHE_FILE = "cursor-client-version.json";
|
|
12
14
|
export declare const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
|
package/dist/shared.js
CHANGED
|
@@ -7,6 +7,8 @@ export const RUN_PATH = "/agent.v1.AgentService/Run";
|
|
|
7
7
|
export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
|
|
8
8
|
export const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
|
|
9
9
|
export const MODEL_CACHE_FILE = "cursor-models.json";
|
|
10
|
+
/** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
|
|
11
|
+
export const MODEL_CACHE_SCHEMA_VERSION = 2;
|
|
10
12
|
export const MODEL_CACHE_TTL_MS = 86_400_000;
|
|
11
13
|
export const VERSION_CACHE_FILE = "cursor-client-version.json";
|
|
12
14
|
export const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
|
|
@@ -36,7 +36,17 @@ export async function unaryAvailableModels(token, options = {}) {
|
|
|
36
36
|
"content-type": "application/json",
|
|
37
37
|
accept: "application/json",
|
|
38
38
|
},
|
|
39
|
-
|
|
39
|
+
// AvailableModelsRequest flags (proto aiserver.v1). The IDE sets these
|
|
40
|
+
// (modelConfigService.js useModelParameters, entry.js includeLongContextModels).
|
|
41
|
+
// useModelParameters + useCloudAgentEffortModes return parameterized
|
|
42
|
+
// variants (effort/context/fast). includeLongContextModels may populate
|
|
43
|
+
// context_token_limit fields; when those stay empty we still derive limits
|
|
44
|
+
// from each variant's `context` param in mapAvailableModelsResponse.
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
includeLongContextModels: true,
|
|
47
|
+
useModelParameters: true,
|
|
48
|
+
useCloudAgentEffortModes: true,
|
|
49
|
+
}),
|
|
40
50
|
});
|
|
41
51
|
if (!res.ok) {
|
|
42
52
|
const text = await res.text().catch(() => "");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-opencode-provider",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,4 +62,4 @@
|
|
|
62
62
|
"@types/node": "^22.15.3",
|
|
63
63
|
"typescript": "^5.8.3"
|
|
64
64
|
}
|
|
65
|
-
}
|
|
65
|
+
}
|