cursor-opencode-provider 0.1.4 → 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 +30 -4
- package/dist/agent-url.js +2 -1
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +22 -0
- package/dist/language-model.js +26 -9
- 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/protocol/tools.d.ts +28 -1
- package/dist/protocol/tools.js +81 -5
- package/dist/session.d.ts +6 -1
- package/dist/session.js +3 -3
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/transport/connect.d.ts +0 -1
- package/dist/transport/connect.js +12 -22
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
|
|
|
18
18
|
- **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
|
|
19
19
|
- **Model discovery** — fetches available models from Cursor's API and caches them locally
|
|
20
20
|
- **Streaming** — bidirectional Connect-RPC stream for agent runs
|
|
21
|
-
- **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts
|
|
21
|
+
- **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
|
|
22
22
|
- **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
@@ -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
|
|
@@ -181,6 +206,7 @@ OpenCode
|
|
|
181
206
|
| `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
|
|
182
207
|
| `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
|
|
183
208
|
| `src/session.ts` | Held-open agent Run session and pending exec correlation |
|
|
209
|
+
| `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
|
|
184
210
|
| `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
|
|
185
211
|
| `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
|
|
186
212
|
| `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
|
package/dist/agent-url.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { fetchAgentUrl
|
|
2
|
+
import { fetchAgentUrl } from "./transport/connect.js";
|
|
3
|
+
import { trace } from "./debug.js";
|
|
3
4
|
import { CURSOR_API_HOST } from "./shared.js";
|
|
4
5
|
const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
|
|
5
6
|
// In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
|
package/dist/debug.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function trace(msg: string): void;
|
package/dist/debug.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
// Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
|
|
3
|
+
// Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
|
|
4
|
+
// Truncated once per process. Tokens / checksums should be redacted by callers.
|
|
5
|
+
const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
|
|
6
|
+
process.env.CURSOR_PROVIDER_DEBUG === "true";
|
|
7
|
+
const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
|
|
8
|
+
let _traceInitialized = false;
|
|
9
|
+
export function trace(msg) {
|
|
10
|
+
if (!DEBUG_ENABLED)
|
|
11
|
+
return;
|
|
12
|
+
try {
|
|
13
|
+
if (!_traceInitialized) {
|
|
14
|
+
_traceInitialized = true;
|
|
15
|
+
fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
|
|
16
|
+
}
|
|
17
|
+
fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* ignore */
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/language-model.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { bidiRunStream, normalizeAgentRunOrigin
|
|
3
|
+
import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
|
|
4
|
+
import { trace } from "./debug.js";
|
|
4
5
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
5
6
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
6
7
|
import { decodeMessage } from "./protocol/messages.js";
|
|
@@ -9,7 +10,7 @@ import { handleKvServerMessage } from "./protocol/kv.js";
|
|
|
9
10
|
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
10
11
|
import { conversationBlobCount } from "./protocol/blob-store.js";
|
|
11
12
|
import { sessionManager } from "./session.js";
|
|
12
|
-
import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
|
|
13
|
+
import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
13
14
|
import { buildRequestContext } from "./context/build.js";
|
|
14
15
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
15
16
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
@@ -78,6 +79,7 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
78
79
|
resultField: pending.resultField,
|
|
79
80
|
output: r.output,
|
|
80
81
|
error: r.error,
|
|
82
|
+
toolName: pending.toolName ?? r.toolName,
|
|
81
83
|
})) {
|
|
82
84
|
session.stream.write(frame);
|
|
83
85
|
}
|
|
@@ -182,10 +184,24 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
182
184
|
telemetryEnabled: resolveTelemetryEnabled(options),
|
|
183
185
|
}));
|
|
184
186
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
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);
|
|
189
205
|
// Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
|
|
190
206
|
// that signal when a turn ends with tool-calls; the Cursor stream must stay
|
|
191
207
|
// open until we write the exec results on the next doStream.
|
|
@@ -206,7 +222,7 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
206
222
|
const conversationState = getCheckpoint(conversationId);
|
|
207
223
|
const reqBytes = buildRunRequest({
|
|
208
224
|
text: userText,
|
|
209
|
-
modelId,
|
|
225
|
+
modelId: cursorModelId,
|
|
210
226
|
conversationId,
|
|
211
227
|
systemPrompt: conversationState ? undefined : systemPrompt,
|
|
212
228
|
conversationState,
|
|
@@ -225,7 +241,7 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
225
241
|
const hooksCtx = typeof requestContext.hooks_additional_context === "string"
|
|
226
242
|
? requestContext.hooks_additional_context
|
|
227
243
|
: "";
|
|
228
|
-
trace(`outbound Run: model=${modelId} conversationId=${conversationId} ` +
|
|
244
|
+
trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
|
|
229
245
|
`params=${JSON.stringify(parameterValues ?? [])} ` +
|
|
230
246
|
`maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} tools=${tools.length} ` +
|
|
231
247
|
`skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
|
|
@@ -575,6 +591,7 @@ async function pump(session, controller, ids, abortSignal) {
|
|
|
575
591
|
resultField: parsed.resultField,
|
|
576
592
|
output: "",
|
|
577
593
|
error: reason,
|
|
594
|
+
toolName: parsed.toolName,
|
|
578
595
|
})) {
|
|
579
596
|
session.stream.write(frame);
|
|
580
597
|
}
|
|
@@ -586,7 +603,7 @@ async function pump(session, controller, ids, abortSignal) {
|
|
|
586
603
|
}
|
|
587
604
|
const tc = buildToolCallPart(parsed, session.sessionId);
|
|
588
605
|
// Keep the stream open; the result arrives on the next doStream call.
|
|
589
|
-
sessionManager.registerPending(parsed.id, session, parsed.resultField);
|
|
606
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
|
|
590
607
|
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
591
608
|
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
592
609
|
// Close open text/reasoning spans before tool-call (required by AI SDK V3).
|
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/protocol/tools.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type ToolResultInput = {
|
|
|
53
53
|
output: string;
|
|
54
54
|
error?: string;
|
|
55
55
|
executionTimeMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Resolved opencode tool name (read/write/grep/…). Gates read-envelope
|
|
58
|
+
* unwrapping on the mcp_result path so non-read MCP output is never
|
|
59
|
+
* rewritten. read_result is always a read, so it unwraps regardless.
|
|
60
|
+
*/
|
|
61
|
+
toolName?: string;
|
|
56
62
|
};
|
|
57
63
|
/**
|
|
58
64
|
* Build one or more ExecClientMessage frames for a tool result.
|
|
@@ -64,12 +70,33 @@ export type ToolResultInput = {
|
|
|
64
70
|
export declare function buildExecClientMessages(input: ToolResultInput): Uint8Array[];
|
|
65
71
|
/** ACM #5 exec_client_control_message { stream_close { id } }. */
|
|
66
72
|
export declare function buildExecStreamClose(execId: number): Uint8Array;
|
|
73
|
+
/**
|
|
74
|
+
* Strip opencode's `read` envelope, leaving raw file content.
|
|
75
|
+
*
|
|
76
|
+
* opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
|
|
77
|
+
* envelope its own models are trained on, but Cursor's are not:
|
|
78
|
+
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
79
|
+
* Forwarding that envelope verbatim made Cursor's model treat the wrapper as
|
|
80
|
+
* literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
|
|
81
|
+
* back into files (silent corruption — the write still reports success; seen
|
|
82
|
+
* across 8+ sessions, e.g. language-model.ts rewritten starting with
|
|
83
|
+
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
84
|
+
*
|
|
85
|
+
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
86
|
+
*
|
|
87
|
+
* Deliberately exception-safe: if the expected envelope is absent — non-read
|
|
88
|
+
* output, already-raw text, or a future opencode format change — it returns the
|
|
89
|
+
* input unchanged, so a result is never broken and we never throw mid-turn.
|
|
90
|
+
* Callers must still gate `mcp_result` on `toolName === "read"`; this helper
|
|
91
|
+
* alone is not a tool-identity check.
|
|
92
|
+
*/
|
|
93
|
+
export declare function unwrapReadOutput(output: string): string;
|
|
67
94
|
/**
|
|
68
95
|
* Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
|
|
69
96
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
70
97
|
* server accepts (verified against agent.v1 wire captures).
|
|
71
98
|
*/
|
|
72
|
-
export declare function buildTypedExecResult(resultField: string, output: string, error?: string): Record<string, unknown>;
|
|
99
|
+
export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string): Record<string, unknown>;
|
|
73
100
|
export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
|
|
74
101
|
toolCallId: string;
|
|
75
102
|
toolName: string;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { encodeMessage } from "./messages.js";
|
|
2
2
|
import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
|
|
3
|
+
import { trace } from "../debug.js";
|
|
3
4
|
// Exec variant field number whose reply is the server-initiated request_context
|
|
4
5
|
// probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
|
|
5
6
|
// field number for every exec variant, so this is also the result field.
|
|
@@ -394,7 +395,7 @@ export function buildExecClientMessages(input) {
|
|
|
394
395
|
id: input.execId,
|
|
395
396
|
local_execution_time_ms: input.executionTimeMs ?? 0,
|
|
396
397
|
};
|
|
397
|
-
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error);
|
|
398
|
+
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName);
|
|
398
399
|
frames.push(encodeMessage("AgentClientMessage", {
|
|
399
400
|
exec_client_message: clientMsg,
|
|
400
401
|
}));
|
|
@@ -411,21 +412,90 @@ export function buildExecStreamClose(execId) {
|
|
|
411
412
|
},
|
|
412
413
|
});
|
|
413
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Strip opencode's `read` envelope, leaving raw file content.
|
|
417
|
+
*
|
|
418
|
+
* opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
|
|
419
|
+
* envelope its own models are trained on, but Cursor's are not:
|
|
420
|
+
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
421
|
+
* Forwarding that envelope verbatim made Cursor's model treat the wrapper as
|
|
422
|
+
* literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
|
|
423
|
+
* back into files (silent corruption — the write still reports success; seen
|
|
424
|
+
* across 8+ sessions, e.g. language-model.ts rewritten starting with
|
|
425
|
+
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
426
|
+
*
|
|
427
|
+
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
428
|
+
*
|
|
429
|
+
* Deliberately exception-safe: if the expected envelope is absent — non-read
|
|
430
|
+
* output, already-raw text, or a future opencode format change — it returns the
|
|
431
|
+
* input unchanged, so a result is never broken and we never throw mid-turn.
|
|
432
|
+
* Callers must still gate `mcp_result` on `toolName === "read"`; this helper
|
|
433
|
+
* alone is not a tool-identity check.
|
|
434
|
+
*/
|
|
435
|
+
export function unwrapReadOutput(output) {
|
|
436
|
+
if (typeof output !== "string" || output.length === 0)
|
|
437
|
+
return output;
|
|
438
|
+
// Require the full opencode read-envelope skeleton *before* `<content>`
|
|
439
|
+
// (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
|
|
440
|
+
// stray "<content>" later in tool chatter can't trigger unwrapping just
|
|
441
|
+
// because path/type tags appear elsewhere in the payload.
|
|
442
|
+
const contentHeaderIdx = output.indexOf("<content>");
|
|
443
|
+
if (contentHeaderIdx === -1)
|
|
444
|
+
return output;
|
|
445
|
+
const header = output.slice(0, contentHeaderIdx);
|
|
446
|
+
const hasSkeleton = header.indexOf("<path>") !== -1 &&
|
|
447
|
+
header.indexOf("<type>file</type>") !== -1;
|
|
448
|
+
if (!hasSkeleton) {
|
|
449
|
+
// Saw "<content>" but not the read skeleton ahead of it — almost certainly
|
|
450
|
+
// a non-read payload, or an opencode format drift. Surface it so drift
|
|
451
|
+
// can't silently resurrect the wrapper-corruption bug, but still fail
|
|
452
|
+
// safe (no mutate).
|
|
453
|
+
trace("unwrapReadOutput: <content> present without leading <path>/<type>file> skeleton — leaving output unchanged (possible non-read payload or opencode read format drift)");
|
|
454
|
+
return output;
|
|
455
|
+
}
|
|
456
|
+
// Body starts right after "<content>\n". opencode emits one numbered line per
|
|
457
|
+
// file line ("N: <line>"), then a blank, a "(…)" footer, and a standalone
|
|
458
|
+
// "</content>". The body is a *contiguous run* of /^N: / lines — so we stop at
|
|
459
|
+
// the first non-numbered line. Critically we do NOT search for a closing
|
|
460
|
+
// "</content>" substring: a file line that literally contains "</content>"
|
|
461
|
+
// is rendered as "N: </content>" (a body line), and a raw indexOf would
|
|
462
|
+
// truncate the read there.
|
|
463
|
+
let rest = output.slice(contentHeaderIdx + "<content>".length);
|
|
464
|
+
if (rest.startsWith("\n"))
|
|
465
|
+
rest = rest.slice(1);
|
|
466
|
+
const raw = [];
|
|
467
|
+
for (const line of rest.split("\n")) {
|
|
468
|
+
const m = /^(\d+):[ \t]?(.*)$/.exec(line);
|
|
469
|
+
if (!m)
|
|
470
|
+
break; // blank / "(footer)" / "</content>" → end of body run
|
|
471
|
+
// Strip only the leading "N: " prefix; a line that itself begins with
|
|
472
|
+
// digits+colon keeps its content (we remove just the first match). Blank
|
|
473
|
+
// file lines render as "N: " and are preserved (capture group is "").
|
|
474
|
+
raw.push(m[2]);
|
|
475
|
+
}
|
|
476
|
+
// Envelope confirmed but no numbered body → empty file. Return "" rather
|
|
477
|
+
// than the envelope (the envelope is exactly what Cursor echoes into writes).
|
|
478
|
+
return raw.join("\n");
|
|
479
|
+
}
|
|
414
480
|
/**
|
|
415
481
|
* Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
|
|
416
482
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
417
483
|
* server accepts (verified against agent.v1 wire captures).
|
|
418
484
|
*/
|
|
419
|
-
export function buildTypedExecResult(resultField, output, error) {
|
|
485
|
+
export function buildTypedExecResult(resultField, output, error, toolName) {
|
|
420
486
|
switch (resultField) {
|
|
421
487
|
case "read_result":
|
|
422
488
|
if (error)
|
|
423
489
|
return { error: { path: "", error } };
|
|
490
|
+
// Strip opencode's <path>/<content> envelope so Cursor's model receives
|
|
491
|
+
// raw file content and can't echo the wrapper into subsequent writes.
|
|
492
|
+
// reads route here only for native read_args; most arrive via mcp_result.
|
|
493
|
+
const content = unwrapReadOutput(output);
|
|
424
494
|
return {
|
|
425
495
|
success: {
|
|
426
496
|
path: extractPathTag(output) ?? "",
|
|
427
|
-
content
|
|
428
|
-
total_lines: countLines(
|
|
497
|
+
content,
|
|
498
|
+
total_lines: countLines(content),
|
|
429
499
|
},
|
|
430
500
|
};
|
|
431
501
|
case "grep_result": {
|
|
@@ -492,9 +562,15 @@ export function buildTypedExecResult(resultField, output, error) {
|
|
|
492
562
|
case "mcp_result":
|
|
493
563
|
if (error)
|
|
494
564
|
return { error: { error } };
|
|
565
|
+
// opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
|
|
566
|
+
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
567
|
+
// "read" so a non-read MCP tool whose output merely contains a
|
|
568
|
+
// "<content>"-like block is never rewritten.
|
|
495
569
|
return {
|
|
496
570
|
success: {
|
|
497
|
-
content: [
|
|
571
|
+
content: [
|
|
572
|
+
{ text: { text: toolName === "read" ? unwrapReadOutput(output) : output } },
|
|
573
|
+
],
|
|
498
574
|
is_error: false,
|
|
499
575
|
},
|
|
500
576
|
};
|
package/dist/session.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export type Frame = {
|
|
|
14
14
|
export type PendingExec = {
|
|
15
15
|
/** ExecClientMessage result field to reply with (matches the request variant). */
|
|
16
16
|
resultField: string;
|
|
17
|
+
/**
|
|
18
|
+
* Resolved opencode tool name (read/write/grep/…). Used on continuation so
|
|
19
|
+
* mcp_result can unwrap read envelopes even if the prompt omits toolName.
|
|
20
|
+
*/
|
|
21
|
+
toolName?: string;
|
|
17
22
|
};
|
|
18
23
|
export type CursorSession = {
|
|
19
24
|
/**
|
|
@@ -58,7 +63,7 @@ export declare class SessionManager {
|
|
|
58
63
|
constructor(idleTimeoutMs?: number);
|
|
59
64
|
touch(session: CursorSession): void;
|
|
60
65
|
/** Register that `session` is awaiting a result for `execId`. */
|
|
61
|
-
registerPending(execId: number, session: CursorSession, resultField: string): void;
|
|
66
|
+
registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string): void;
|
|
62
67
|
/** The pending exec info for an id on a specific session, if still awaiting it. */
|
|
63
68
|
pendingFor(sessionId: string, execId: number): PendingExec | undefined;
|
|
64
69
|
/** Find the live session awaiting one of the given exec ids. */
|
package/dist/session.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { trace } from "./
|
|
1
|
+
import { trace } from "./debug.js";
|
|
2
2
|
export class SessionManager {
|
|
3
3
|
// Composite key `${sessionId}:${execId}` → owning session. Composite keying
|
|
4
4
|
// means two Run streams that both register an execId of 1 (Cursor resets
|
|
@@ -12,8 +12,8 @@ export class SessionManager {
|
|
|
12
12
|
session.expiresAt = Date.now() + this.idleTimeoutMs;
|
|
13
13
|
}
|
|
14
14
|
/** Register that `session` is awaiting a result for `execId`. */
|
|
15
|
-
registerPending(execId, session, resultField) {
|
|
16
|
-
session.pending.set(execId, { resultField });
|
|
15
|
+
registerPending(execId, session, resultField, toolName) {
|
|
16
|
+
session.pending.set(execId, { resultField, toolName });
|
|
17
17
|
this.byExecId.set(this.key(session.sessionId, execId), session);
|
|
18
18
|
this.touch(session);
|
|
19
19
|
}
|
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";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export declare function trace(msg: string): void;
|
|
2
1
|
export declare function buildBaseHeaders(token: string, clientVersion: string, extra?: Record<string, string>): Record<string, string>;
|
|
3
2
|
export declare function unaryAvailableModels(token: string, options?: {
|
|
4
3
|
apiBaseURL?: string;
|
|
@@ -3,32 +3,12 @@ import { encodeFrame, streamFrames } from "../protocol/framing.js";
|
|
|
3
3
|
import { createCursorChecksumHeader } from "../protocol/checksum.js";
|
|
4
4
|
import { getDeviceIds } from "../protocol/device-id.js";
|
|
5
5
|
import { resolveClientVersion } from "../protocol/client-version.js";
|
|
6
|
+
import { trace } from "../debug.js";
|
|
6
7
|
import http2 from "node:http2";
|
|
7
|
-
import fs from "node:fs";
|
|
8
8
|
const API_BASE = `https://${CURSOR_API_HOST}`;
|
|
9
9
|
function resolveApiBaseURL(options) {
|
|
10
10
|
return new URL(options.apiBaseURL ?? options.baseURL ?? API_BASE).origin;
|
|
11
11
|
}
|
|
12
|
-
// Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
|
|
13
|
-
// Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
|
|
14
|
-
// Truncated once per process. Captures h2 response status, parsed frames, and
|
|
15
|
-
// stream errors. Tokens / checksums are redacted in header dumps.
|
|
16
|
-
const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
|
|
17
|
-
process.env.CURSOR_PROVIDER_DEBUG === "true";
|
|
18
|
-
const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
|
|
19
|
-
let _traceInitialized = false;
|
|
20
|
-
export function trace(msg) {
|
|
21
|
-
if (!DEBUG_ENABLED)
|
|
22
|
-
return;
|
|
23
|
-
try {
|
|
24
|
-
if (!_traceInitialized) {
|
|
25
|
-
_traceInitialized = true;
|
|
26
|
-
fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
|
|
27
|
-
}
|
|
28
|
-
fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
|
|
29
|
-
}
|
|
30
|
-
catch { /* ignore */ }
|
|
31
|
-
}
|
|
32
12
|
trace("connect.ts module loaded");
|
|
33
13
|
export function buildBaseHeaders(token, clientVersion, extra) {
|
|
34
14
|
const { machineId, macMachineId } = getDeviceIds();
|
|
@@ -56,7 +36,17 @@ export async function unaryAvailableModels(token, options = {}) {
|
|
|
56
36
|
"content-type": "application/json",
|
|
57
37
|
accept: "application/json",
|
|
58
38
|
},
|
|
59
|
-
|
|
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
|
+
}),
|
|
60
50
|
});
|
|
61
51
|
if (!res.ok) {
|
|
62
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",
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
"protobufjs": "^7.4.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@opencode-ai/plugin": "
|
|
57
|
+
"@opencode-ai/plugin": "^1.17.13"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@opencode-ai/plugin": "
|
|
60
|
+
"@opencode-ai/plugin": "^1.17.13",
|
|
61
61
|
"@tsconfig/node22": "^22.0.1",
|
|
62
62
|
"@types/node": "^22.15.3",
|
|
63
63
|
"typescript": "^5.8.3"
|