cursor-opencode-provider 0.2.9 → 0.3.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 +139 -10
- package/dist/agent-url.js +2 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +9 -0
- package/dist/compaction-marker.d.ts +14 -0
- package/dist/compaction-marker.js +36 -0
- package/dist/context/env.js +6 -2
- package/dist/context/paths.js +3 -2
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2 -8
- package/dist/language-model.js +51 -35
- package/dist/model-config.d.ts +15 -0
- package/dist/model-config.js +206 -0
- package/dist/opencode2/catalog.d.ts +33 -0
- package/dist/opencode2/catalog.js +85 -0
- package/dist/opencode2/integration.d.ts +23 -0
- package/dist/opencode2/integration.js +124 -0
- package/dist/opencode2/types.d.ts +316 -0
- package/dist/opencode2/types.js +18 -0
- package/dist/plugin-core.d.ts +27 -0
- package/dist/plugin-core.js +31 -0
- package/dist/plugin-opencode2.d.ts +3 -0
- package/dist/plugin-opencode2.js +293 -0
- package/dist/plugin-v2.d.ts +7 -0
- package/dist/plugin-v2.js +1 -15
- package/dist/plugin.d.ts +1 -15
- package/dist/plugin.js +11 -191
- package/dist/protocol/apply-patch.d.ts +109 -0
- package/dist/protocol/apply-patch.js +163 -0
- package/dist/protocol/messages.d.ts +10 -0
- package/dist/protocol/messages.js +83 -1
- package/dist/protocol/tools.d.ts +24 -1
- package/dist/protocol/tools.js +262 -19
- package/dist/session-directory.d.ts +17 -0
- package/dist/session-directory.js +35 -0
- package/dist/session.d.ts +13 -1
- package/dist/session.js +70 -1
- package/dist/shell-timeout.d.ts +3 -1
- package/dist/shell-timeout.js +12 -4
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +77 -0
- package/dist/web-search-tool.d.ts +33 -0
- package/dist/web-search-tool.js +22 -0
- package/dist/web-tools.d.ts +9 -25
- package/dist/web-tools.js +34 -36
- package/package.json +14 -9
package/dist/language-model.js
CHANGED
|
@@ -5,8 +5,8 @@ import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } fr
|
|
|
5
5
|
import { trace, traceRequestContextPaths } from "./debug.js";
|
|
6
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
7
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
8
|
-
import { decodeMessage } from "./protocol/messages.js";
|
|
9
|
-
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
8
|
+
import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
|
|
9
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
10
10
|
import { describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
11
11
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
12
12
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
@@ -22,9 +22,12 @@ import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
|
22
22
|
import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
|
|
23
23
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
24
24
|
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
|
|
25
|
+
import { isCompactionSession } from "./compaction-marker.js";
|
|
26
|
+
import { getSessionDirectory } from "./session-directory.js";
|
|
25
27
|
import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
|
|
26
28
|
import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
|
|
27
29
|
import { readAllFieldsStrict } from "./protocol/struct.js";
|
|
30
|
+
import { buildLanguageModelV3UsageFromEstimate, buildLanguageModelV3UsageFromTurnEnded, turnEndedCounter, } from "./usage.js";
|
|
28
31
|
let _availableModels;
|
|
29
32
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
30
33
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
@@ -275,7 +278,7 @@ export function createCursorLanguageModel(modelId, providerId, options) {
|
|
|
275
278
|
};
|
|
276
279
|
}
|
|
277
280
|
async function doStreamImpl(modelId, options, callOptions) {
|
|
278
|
-
// A raw `
|
|
281
|
+
// A raw `crsr_...` API key must be exchanged for a JWT before it can be used
|
|
279
282
|
// as a Bearer token (the plugin path does this in auth.ts). The accessToken
|
|
280
283
|
// path is already a JWT from OAuth/key-exchange, so we use it as-is.
|
|
281
284
|
// resolveBearerToken caches apiKey exchanges so we don't hit /auth/exchange
|
|
@@ -445,8 +448,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
445
448
|
const sessionKey = opencodeSessionKey(callOptions);
|
|
446
449
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
447
450
|
// The classic plugin marks OpenCode's agent="compaction" through chat.params.
|
|
451
|
+
// OpenCode 2.0 removed that hook, so its plugin records the same fact against
|
|
452
|
+
// the session id from session.hook("context") instead; consult both.
|
|
448
453
|
// Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
|
|
449
|
-
const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true;
|
|
454
|
+
const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
|
|
450
455
|
const toolState = resolveTurnToolState({
|
|
451
456
|
sessionKey,
|
|
452
457
|
incomingTools,
|
|
@@ -482,7 +487,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
482
487
|
const userText = recovery?.kind === "rebase"
|
|
483
488
|
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
484
489
|
: (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
|
|
485
|
-
|
|
490
|
+
// v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
|
|
491
|
+
// one plugin instance per project). OpenCode 2.0 runs one daemon across many
|
|
492
|
+
// projects, so its static `options.workspaceRoot` is only a last-resort
|
|
493
|
+
// fallback; `getSessionDirectory` carries the real per-session directory
|
|
494
|
+
// recorded from `session.hook("context")` in `plugin-opencode2.ts`.
|
|
495
|
+
const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
|
|
486
496
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
487
497
|
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
488
498
|
const systemPrompt = interactionGuidance
|
|
@@ -583,6 +593,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
583
593
|
outputTokens: 0,
|
|
584
594
|
cacheRead: 0,
|
|
585
595
|
cacheWrite: 0,
|
|
596
|
+
reasoningTokens: 0,
|
|
586
597
|
};
|
|
587
598
|
trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
|
|
588
599
|
`params=${JSON.stringify(parameterValues ?? [])} ` +
|
|
@@ -730,6 +741,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
730
741
|
toolName: pending.toolName ?? r.toolName,
|
|
731
742
|
resultMetadata: pending.resultMetadata,
|
|
732
743
|
shellOutcome: shellResult?.outcome,
|
|
744
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
733
745
|
});
|
|
734
746
|
}
|
|
735
747
|
catch (error) {
|
|
@@ -916,6 +928,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
916
928
|
error: reason,
|
|
917
929
|
toolName: parsed.toolName,
|
|
918
930
|
resultMetadata: parsed.resultMetadata,
|
|
931
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
919
932
|
})) {
|
|
920
933
|
session.stream.write(frame);
|
|
921
934
|
}
|
|
@@ -941,7 +954,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
941
954
|
if (!displayCallId ||
|
|
942
955
|
parsed.resultField !== "read_result" ||
|
|
943
956
|
parsed.toolName !== "read" ||
|
|
944
|
-
|
|
957
|
+
// `apply_patch` is the substitute this host offers when it withholds
|
|
958
|
+
// `write` — the follow-up write_args is remapped onto it either way.
|
|
959
|
+
!(advertisedToolNameSet.has("write") || advertisedToolNameSet.has("apply_patch")))
|
|
945
960
|
return false;
|
|
946
961
|
const stored = session.displayToolCalls.get(displayCallId);
|
|
947
962
|
const display = parseDisplayToolCall(displayCallId, stored);
|
|
@@ -965,6 +980,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
965
980
|
output: "",
|
|
966
981
|
toolName: parsed.toolName,
|
|
967
982
|
resultMetadata: { path: requestedPath },
|
|
983
|
+
workspaceRoot,
|
|
968
984
|
})) {
|
|
969
985
|
session.stream.write(frame);
|
|
970
986
|
}
|
|
@@ -1067,32 +1083,27 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1067
1083
|
if (te) {
|
|
1068
1084
|
// Authoritative TurnEnded counts — replace the running estimate.
|
|
1069
1085
|
session.usageEstimate = {
|
|
1070
|
-
inputTokens:
|
|
1071
|
-
outputTokens:
|
|
1072
|
-
cacheRead:
|
|
1073
|
-
cacheWrite:
|
|
1086
|
+
inputTokens: turnEndedCounter(te, "input_tokens"),
|
|
1087
|
+
outputTokens: turnEndedCounter(te, "output_tokens"),
|
|
1088
|
+
cacheRead: turnEndedCounter(te, "cache_read"),
|
|
1089
|
+
cacheWrite: turnEndedCounter(te, "cache_write"),
|
|
1090
|
+
reasoningTokens: turnEndedCounter(te, "reasoning_tokens"),
|
|
1074
1091
|
};
|
|
1075
1092
|
}
|
|
1076
1093
|
const est = session.usageEstimate;
|
|
1077
|
-
const usage =
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
noCache: undefined,
|
|
1081
|
-
cacheRead: 0,
|
|
1082
|
-
cacheWrite: 0,
|
|
1083
|
-
},
|
|
1084
|
-
outputTokens: {
|
|
1085
|
-
total: estimateTokens(requestUsage.outputChars),
|
|
1086
|
-
text: undefined,
|
|
1087
|
-
reasoning: undefined,
|
|
1088
|
-
},
|
|
1089
|
-
};
|
|
1094
|
+
const usage = te
|
|
1095
|
+
? buildLanguageModelV3UsageFromTurnEnded(te)
|
|
1096
|
+
: buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
|
|
1090
1097
|
const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
|
|
1091
1098
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
1092
1099
|
? String(reason.unified ?? "unknown")
|
|
1093
1100
|
: String(reason);
|
|
1101
|
+
const inTotal = usage.inputTokens?.total ?? 0;
|
|
1102
|
+
const outTotal = usage.outputTokens?.total ?? 0;
|
|
1094
1103
|
trace(`finish: reason=${reasonLabel} ` +
|
|
1095
|
-
`
|
|
1104
|
+
`v3In=${inTotal} v3Out=${outTotal} ` +
|
|
1105
|
+
`v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
|
|
1106
|
+
`v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
|
|
1096
1107
|
`rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
|
|
1097
1108
|
`rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
|
|
1098
1109
|
`source=${te ? "turn_ended" : "estimate"}`);
|
|
@@ -1238,6 +1249,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1238
1249
|
emitReasoning(iu.thinking_delta.text ?? "");
|
|
1239
1250
|
}
|
|
1240
1251
|
else if (iu?.turn_ended) {
|
|
1252
|
+
trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
|
|
1241
1253
|
emitFinish(iu.turn_ended, { unified: "stop", raw: undefined });
|
|
1242
1254
|
sessionManager.close(session);
|
|
1243
1255
|
return;
|
|
@@ -1367,6 +1379,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1367
1379
|
parsed.toolName = executableToolName;
|
|
1368
1380
|
}
|
|
1369
1381
|
remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
|
|
1382
|
+
// Hosts that advertise `apply_patch` in place of `edit`/`write` (see
|
|
1383
|
+
// protocol/apply-patch.ts) still receive Cursor's native write/edit
|
|
1384
|
+
// exec requests. Translate before the unavailable-tool check below.
|
|
1385
|
+
remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
|
|
1370
1386
|
}
|
|
1371
1387
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
1372
1388
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
@@ -1653,6 +1669,12 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1653
1669
|
? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
|
|
1654
1670
|
: "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
|
|
1655
1671
|
}
|
|
1672
|
+
else if (names.has("apply_patch")) {
|
|
1673
|
+
// This host withheld `edit`/`write` and offers `apply_patch` instead. Native
|
|
1674
|
+
// Cursor write/edit requests are translated into it, so say so rather than
|
|
1675
|
+
// leaving the turn with no file-editing guidance at all.
|
|
1676
|
+
instructions.push("- Use OpenCode `apply_patch` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to `apply_patch` automatically.");
|
|
1677
|
+
}
|
|
1656
1678
|
return [
|
|
1657
1679
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1658
1680
|
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
|
@@ -1683,20 +1705,14 @@ export function estimatePromptTokens(prompt) {
|
|
|
1683
1705
|
}
|
|
1684
1706
|
/** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
|
|
1685
1707
|
export function cursorTurnEndedProviderMetadata(te) {
|
|
1686
|
-
const counter = (key) => {
|
|
1687
|
-
const value = te[key];
|
|
1688
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
1689
|
-
? Math.trunc(value)
|
|
1690
|
-
: 0;
|
|
1691
|
-
};
|
|
1692
1708
|
return {
|
|
1693
1709
|
cursor: {
|
|
1694
1710
|
usageVersion: 2,
|
|
1695
|
-
inputTokensRaw:
|
|
1696
|
-
outputTokensRaw:
|
|
1697
|
-
cacheReadRaw:
|
|
1698
|
-
cacheWriteRaw:
|
|
1699
|
-
reasoningTokensRaw:
|
|
1711
|
+
inputTokensRaw: turnEndedCounter(te, "input_tokens"),
|
|
1712
|
+
outputTokensRaw: turnEndedCounter(te, "output_tokens"),
|
|
1713
|
+
cacheReadRaw: turnEndedCounter(te, "cache_read"),
|
|
1714
|
+
cacheWriteRaw: turnEndedCounter(te, "cache_write"),
|
|
1715
|
+
reasoningTokensRaw: turnEndedCounter(te, "reasoning_tokens"),
|
|
1700
1716
|
},
|
|
1701
1717
|
};
|
|
1702
1718
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ModelInfo } from "./models.js";
|
|
2
|
+
/**
|
|
3
|
+
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
4
|
+
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
5
|
+
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
6
|
+
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
7
|
+
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
8
|
+
* tagged redundantly.
|
|
9
|
+
*/
|
|
10
|
+
export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
|
|
11
|
+
export declare function modelInfoToConfig(mi: ModelInfo, options?: {
|
|
12
|
+
thinkingSuffix?: boolean;
|
|
13
|
+
contextTier?: "base" | "long";
|
|
14
|
+
}): Record<string, any>;
|
|
15
|
+
export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorContextLimit, } from "./models.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cursor `ModelInfo` → OpenCode model-config mapping.
|
|
4
|
+
*
|
|
5
|
+
* Host-neutral on purpose: both the classic/1.18 plugins and the OpenCode 2.0
|
|
6
|
+
* plugin derive their catalogs from here, so model naming, thinking suffixes,
|
|
7
|
+
* and long-context tiering stay identical across every surface. Do not import
|
|
8
|
+
* a host plugin API into this module.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Strip characters and markup that break rendering in the OpenCode TUI/GUI from
|
|
12
|
+
* a model or variant display name:
|
|
13
|
+
* • HTML tags (e.g. `<span style="…">Medium</span>`) — the IDE colours variant
|
|
14
|
+
* suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
|
|
15
|
+
* would show as literal text. We drop the tags and keep the inner text
|
|
16
|
+
* ("Medium").
|
|
17
|
+
* • HTML/markup chars (`< > & " ' \``), parentheses.
|
|
18
|
+
* • Tabs/newlines collapse to single spaces.
|
|
19
|
+
* Dots and unicode letters are preserved so names stay readable.
|
|
20
|
+
* Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
|
|
21
|
+
*/
|
|
22
|
+
function stripMarkupTags(value) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let cursor = 0;
|
|
25
|
+
while (cursor < value.length) {
|
|
26
|
+
const start = value.indexOf("<", cursor);
|
|
27
|
+
if (start === -1) {
|
|
28
|
+
chunks.push(value.slice(cursor));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
chunks.push(value.slice(cursor, start));
|
|
32
|
+
const end = value.indexOf(">", start + 1);
|
|
33
|
+
if (end === -1) {
|
|
34
|
+
chunks.push(value.slice(start));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
cursor = end + 1;
|
|
38
|
+
}
|
|
39
|
+
return chunks.join("");
|
|
40
|
+
}
|
|
41
|
+
function safeLabel(value) {
|
|
42
|
+
return (stripMarkupTags(value)
|
|
43
|
+
.replace(/[()<>&"'`]/g, "")
|
|
44
|
+
.replace(/\s+/g, " ")
|
|
45
|
+
.trim() || "default");
|
|
46
|
+
}
|
|
47
|
+
function baseName(mi) {
|
|
48
|
+
return safeLabel(mi.displayName ?? mi.id);
|
|
49
|
+
}
|
|
50
|
+
function modelInfoVariants(mi, variants) {
|
|
51
|
+
if (variants.length === 0)
|
|
52
|
+
return undefined;
|
|
53
|
+
const entries = {};
|
|
54
|
+
const usedKeys = new Set();
|
|
55
|
+
const baseName = safeLabel(mi.displayName ?? mi.id);
|
|
56
|
+
// Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
|
|
57
|
+
// "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
|
|
58
|
+
// sees in Cursor. Two variants can sanitize to the same name when the IDE
|
|
59
|
+
// wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
|
|
60
|
+
// suffix collapses to the bare model name after stripping). To guarantee
|
|
61
|
+
// every variant stays pickable:
|
|
62
|
+
// 1. If the sanitized displayName matches the model name itself, suffix
|
|
63
|
+
// it with distinguishing params (or "default") so it never collides
|
|
64
|
+
// with the model entry in the variant panel.
|
|
65
|
+
// 2. If two variants still collide, tag the later one with its params.
|
|
66
|
+
// Suffixes must stay free of `()` / markup chars — same constraint as
|
|
67
|
+
// safeLabel (issue #2); use spaced tokens, not parenthetical tags.
|
|
68
|
+
const tagDims = (p) => {
|
|
69
|
+
const labels = [];
|
|
70
|
+
for (const d of p) {
|
|
71
|
+
if (d.id === "fast" && d.value === "true")
|
|
72
|
+
labels.push("Fast");
|
|
73
|
+
else if (d.id === "thinking" && d.value === "true")
|
|
74
|
+
labels.push("Thinking");
|
|
75
|
+
else if (d.id === "context")
|
|
76
|
+
labels.push(d.value);
|
|
77
|
+
}
|
|
78
|
+
if (labels.length > 0)
|
|
79
|
+
return ` ${labels.join(" ")}`;
|
|
80
|
+
// No params at all — still disambiguate from the model name itself.
|
|
81
|
+
if (p.length === 0)
|
|
82
|
+
return "";
|
|
83
|
+
return " default";
|
|
84
|
+
};
|
|
85
|
+
for (const v of variants) {
|
|
86
|
+
const sanitized = safeLabel(v.displayName || v.key || "default");
|
|
87
|
+
let key = sanitized;
|
|
88
|
+
// Never let a variant key equal the model name — that would make the
|
|
89
|
+
// variant entry indistinguishable from the model entry in pickers that
|
|
90
|
+
// collapse them.
|
|
91
|
+
if (key === baseName && !usedKeys.has(key)) {
|
|
92
|
+
key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
|
|
93
|
+
}
|
|
94
|
+
else if (usedKeys.has(key)) {
|
|
95
|
+
key = `${sanitized}${tagDims(v.parameterValues)}`;
|
|
96
|
+
}
|
|
97
|
+
let n = 2;
|
|
98
|
+
while (usedKeys.has(key))
|
|
99
|
+
key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
|
|
100
|
+
usedKeys.add(key);
|
|
101
|
+
entries[key] = {
|
|
102
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
function isLongContextVariant(v) {
|
|
108
|
+
return v.parameterValues.some((p) => p.id === "context" && parseCursorContextLimit(p.value) === 1_000_000);
|
|
109
|
+
}
|
|
110
|
+
function variantsForTier(mi, tier) {
|
|
111
|
+
return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
115
|
+
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
116
|
+
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
117
|
+
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
118
|
+
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
119
|
+
* tagged redundantly.
|
|
120
|
+
*/
|
|
121
|
+
export function thinkingSuffixBaseNames(models) {
|
|
122
|
+
const flags = new Map();
|
|
123
|
+
for (const m of models) {
|
|
124
|
+
const base = baseName(m);
|
|
125
|
+
const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
|
|
126
|
+
if (m.supportsThinking)
|
|
127
|
+
entry.hasThinking = true;
|
|
128
|
+
else
|
|
129
|
+
entry.hasNonThinking = true;
|
|
130
|
+
flags.set(base, entry);
|
|
131
|
+
}
|
|
132
|
+
const ambiguous = new Set();
|
|
133
|
+
for (const [base, f] of flags)
|
|
134
|
+
if (f.hasThinking && f.hasNonThinking)
|
|
135
|
+
ambiguous.add(base);
|
|
136
|
+
return ambiguous;
|
|
137
|
+
}
|
|
138
|
+
export function modelInfoToConfig(mi, options = {}) {
|
|
139
|
+
const contextTier = options.contextTier ?? "base";
|
|
140
|
+
const variants = variantsForTier(mi, contextTier);
|
|
141
|
+
let name = baseName(mi);
|
|
142
|
+
if (options.thinkingSuffix)
|
|
143
|
+
name += " Thinking";
|
|
144
|
+
if (contextTier === "long")
|
|
145
|
+
name += " 1M";
|
|
146
|
+
// OpenCode's context limit is static per model entry, while Cursor's context
|
|
147
|
+
// tier is a variant parameter. Long-context choices are therefore emitted as
|
|
148
|
+
// separate OpenCode entries by modelsToConfig.
|
|
149
|
+
const context = contextTier === "long"
|
|
150
|
+
? (mi.maxContextForMaxMode ?? 1_000_000)
|
|
151
|
+
: (mi.maxContext ?? 200_000);
|
|
152
|
+
// OpenCode's overflow/compaction/UI use limit.context; generation and
|
|
153
|
+
// thinking budgets use limit.output. models.dev 1M peers advertise
|
|
154
|
+
// 64k–128k output — a tiny cap makes long-context sessions feel broken
|
|
155
|
+
// even when the 1M input window is correct.
|
|
156
|
+
const output = contextTier === "long" ? 128_000 : 32_000;
|
|
157
|
+
const config = {
|
|
158
|
+
name,
|
|
159
|
+
reasoning: mi.supportsThinking ?? false,
|
|
160
|
+
tool_call: mi.supportsAgent ?? true,
|
|
161
|
+
temperature: false,
|
|
162
|
+
limit: {
|
|
163
|
+
context,
|
|
164
|
+
output,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const variantConfig = modelInfoVariants(mi, variants);
|
|
168
|
+
if (variantConfig)
|
|
169
|
+
config.variants = variantConfig;
|
|
170
|
+
if (contextTier === "long") {
|
|
171
|
+
const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
|
|
172
|
+
if (defaultVariant) {
|
|
173
|
+
config.options = {
|
|
174
|
+
[CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
|
|
175
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return config;
|
|
180
|
+
}
|
|
181
|
+
export function modelsToConfig(models) {
|
|
182
|
+
const ambiguous = thinkingSuffixBaseNames(models);
|
|
183
|
+
const out = {};
|
|
184
|
+
const usedIds = new Set(models.map((m) => m.id));
|
|
185
|
+
for (const m of models) {
|
|
186
|
+
const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
|
|
187
|
+
const baseVariants = variantsForTier(m, "base");
|
|
188
|
+
const longVariants = variantsForTier(m, "long");
|
|
189
|
+
if (baseVariants.length > 0 || longVariants.length === 0) {
|
|
190
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
|
|
191
|
+
}
|
|
192
|
+
if (longVariants.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
if (baseVariants.length === 0) {
|
|
195
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
let longId = `${m.id}-1m`;
|
|
199
|
+
let suffix = 2;
|
|
200
|
+
while (usedIds.has(longId))
|
|
201
|
+
longId = `${m.id}-1m-${suffix++}`;
|
|
202
|
+
usedIds.add(longId);
|
|
203
|
+
out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ModelInfo } from "../models.js";
|
|
2
|
+
import type { CatalogDraft } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Catalog registration for the OpenCode 2.0 plugin — the replacement for the
|
|
5
|
+
* classic plugin's `config` hook.
|
|
6
|
+
*
|
|
7
|
+
* Model naming, thinking suffixes, and long-context tiering are NOT reimplemented
|
|
8
|
+
* here: we run the shared `modelsToConfig` and translate its output into the 2.0
|
|
9
|
+
* `Model.Info` shape, so every surface exposes an identical model list.
|
|
10
|
+
*/
|
|
11
|
+
/** Integration id owning Cursor credentials. Matches the provider id. */
|
|
12
|
+
export declare const CURSOR_INTEGRATION_ID = "cursor";
|
|
13
|
+
/**
|
|
14
|
+
* `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the
|
|
15
|
+
* `aisdk.hook("sdk")` / `("language")` extension points we supply the provider
|
|
16
|
+
* through. The suffix is this package's npm name so the host's built-in
|
|
17
|
+
* `DynamicProviderPlugin` can still resolve it if our own hook is ever
|
|
18
|
+
* bypassed — that fallback runs `npm.add(pkg)` against the *published*
|
|
19
|
+
* registry into `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring
|
|
20
|
+
* any local `file://` plugin path this process was loaded from.
|
|
21
|
+
*
|
|
22
|
+
* `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…`
|
|
23
|
+
* spec instead, pointed at a local built entry file (e.g. `dist/index.js`,
|
|
24
|
+
* which exports `createCursor`). The host's fallback recognizes `file://`
|
|
25
|
+
* specs and imports them directly, skipping `npm.add` — the only way to
|
|
26
|
+
* exercise a local build through that fallback path short of publishing.
|
|
27
|
+
* Unset in production; only meant for local `opencode2 run` testing.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CURSOR_AISDK_PACKAGE: string;
|
|
30
|
+
/** Register (or update) the Cursor provider entry. `update` is an upsert. */
|
|
31
|
+
export declare function applyCursorProvider(draft: CatalogDraft): void;
|
|
32
|
+
/** Register every discovered Cursor model. Safe to re-run; `update` upserts. */
|
|
33
|
+
export declare function applyCursorModels(draft: CatalogDraft, models: ModelInfo[]): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { CURSOR_PROVIDER_ID } from "../shared.js";
|
|
3
|
+
import { CURSOR_WIRE_MODEL_ID_KEY } from "../models.js";
|
|
4
|
+
import { modelsToConfig } from "../model-config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Catalog registration for the OpenCode 2.0 plugin — the replacement for the
|
|
7
|
+
* classic plugin's `config` hook.
|
|
8
|
+
*
|
|
9
|
+
* Model naming, thinking suffixes, and long-context tiering are NOT reimplemented
|
|
10
|
+
* here: we run the shared `modelsToConfig` and translate its output into the 2.0
|
|
11
|
+
* `Model.Info` shape, so every surface exposes an identical model list.
|
|
12
|
+
*/
|
|
13
|
+
/** Integration id owning Cursor credentials. Matches the provider id. */
|
|
14
|
+
export const CURSOR_INTEGRATION_ID = CURSOR_PROVIDER_ID;
|
|
15
|
+
/**
|
|
16
|
+
* `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the
|
|
17
|
+
* `aisdk.hook("sdk")` / `("language")` extension points we supply the provider
|
|
18
|
+
* through. The suffix is this package's npm name so the host's built-in
|
|
19
|
+
* `DynamicProviderPlugin` can still resolve it if our own hook is ever
|
|
20
|
+
* bypassed — that fallback runs `npm.add(pkg)` against the *published*
|
|
21
|
+
* registry into `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring
|
|
22
|
+
* any local `file://` plugin path this process was loaded from.
|
|
23
|
+
*
|
|
24
|
+
* `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…`
|
|
25
|
+
* spec instead, pointed at a local built entry file (e.g. `dist/index.js`,
|
|
26
|
+
* which exports `createCursor`). The host's fallback recognizes `file://`
|
|
27
|
+
* specs and imports them directly, skipping `npm.add` — the only way to
|
|
28
|
+
* exercise a local build through that fallback path short of publishing.
|
|
29
|
+
* Unset in production; only meant for local `opencode2 run` testing.
|
|
30
|
+
*/
|
|
31
|
+
export const CURSOR_AISDK_PACKAGE = process.env.CURSOR_OPENCODE2_DEV_ENTRY
|
|
32
|
+
? `aisdk:${pathToFileURL(process.env.CURSOR_OPENCODE2_DEV_ENTRY).href}`
|
|
33
|
+
: "aisdk:cursor-opencode-provider";
|
|
34
|
+
/** Register (or update) the Cursor provider entry. `update` is an upsert. */
|
|
35
|
+
export function applyCursorProvider(draft) {
|
|
36
|
+
draft.provider.update(CURSOR_PROVIDER_ID, (provider) => {
|
|
37
|
+
provider.id = CURSOR_PROVIDER_ID;
|
|
38
|
+
provider.name = "Cursor";
|
|
39
|
+
provider.package = CURSOR_AISDK_PACKAGE;
|
|
40
|
+
// Links the provider to the integration that stores its credentials, so
|
|
41
|
+
// `connection.active(...)` resolves the token the user set up via /connect.
|
|
42
|
+
provider.integrationID = CURSOR_INTEGRATION_ID;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Translate one `modelsToConfig` entry into the 2.0 `Model.Info` shape. */
|
|
46
|
+
function applyModelEntry(draft, id, entry) {
|
|
47
|
+
const options = entry.options;
|
|
48
|
+
// Long-context entries get a synthetic OpenCode id (`<id>-1m`) while still
|
|
49
|
+
// addressing the same Cursor model on the wire. V1 smuggled that through
|
|
50
|
+
// provider options; 2.0 has a first-class `modelID` for exactly this.
|
|
51
|
+
const wireId = typeof options?.[CURSOR_WIRE_MODEL_ID_KEY] === "string"
|
|
52
|
+
? options[CURSOR_WIRE_MODEL_ID_KEY]
|
|
53
|
+
: id;
|
|
54
|
+
const variants = Object.entries((entry.variants ?? {})).map(([variantId, settings]) => ({ id: variantId, settings: { ...settings } }));
|
|
55
|
+
draft.model.update(CURSOR_PROVIDER_ID, id, (model) => {
|
|
56
|
+
model.id = id;
|
|
57
|
+
model.modelID = wireId;
|
|
58
|
+
model.providerID = CURSOR_PROVIDER_ID;
|
|
59
|
+
model.name = entry.name;
|
|
60
|
+
model.capabilities = {
|
|
61
|
+
tools: entry.tool_call !== false,
|
|
62
|
+
// The provider does not convert file/image prompt parts, so text-only is
|
|
63
|
+
// the honest declaration. Revisit alongside Cursor's `supports_images`.
|
|
64
|
+
input: ["text"],
|
|
65
|
+
output: ["text"],
|
|
66
|
+
};
|
|
67
|
+
model.limit = { context: entry.limit.context, output: entry.limit.output };
|
|
68
|
+
model.variants = variants;
|
|
69
|
+
model.status = "active";
|
|
70
|
+
model.enabled = true;
|
|
71
|
+
model.time = { released: 0 };
|
|
72
|
+
model.cost = [];
|
|
73
|
+
// Carries the default variant parameters (and the wire id) through to
|
|
74
|
+
// doStream as provider options, matching the classic plugin.
|
|
75
|
+
if (options)
|
|
76
|
+
model.settings = { ...options };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Register every discovered Cursor model. Safe to re-run; `update` upserts. */
|
|
80
|
+
export function applyCursorModels(draft, models) {
|
|
81
|
+
const config = modelsToConfig(models);
|
|
82
|
+
for (const [id, entry] of Object.entries(config)) {
|
|
83
|
+
applyModelEntry(draft, id, entry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CredentialValue, IntegrationDomain, IntegrationDraft } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Integration registration for the OpenCode 2.0 plugin — the replacement for the
|
|
4
|
+
* classic plugin's `auth` hook (`methods` + `loader`).
|
|
5
|
+
*
|
|
6
|
+
* Unlike the 1.18 v2 API (where OAuth registration is Effect-valued), 2.0 takes
|
|
7
|
+
* plain Promises, so the PKCE flow from `src/auth.ts` ports across unchanged.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CURSOR_OAUTH_METHOD_ID = "oauth";
|
|
10
|
+
/** Env vars that can supply a Cursor API key without running /connect. */
|
|
11
|
+
export declare const CURSOR_ENV_NAMES: string[];
|
|
12
|
+
/** Register the Cursor integration and its three connection methods. */
|
|
13
|
+
export declare function applyCursorIntegration(draft: IntegrationDraft): void;
|
|
14
|
+
/**
|
|
15
|
+
* Turn a stored credential into a Cursor access token.
|
|
16
|
+
*
|
|
17
|
+
* OAuth credentials already hold a JWT (the host refreshes them via `refresh`
|
|
18
|
+
* above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
|
|
19
|
+
* to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
|
|
20
|
+
*/
|
|
21
|
+
export declare function accessTokenFromCredential(credential: CredentialValue | undefined): Promise<string | undefined>;
|
|
22
|
+
/** Resolve the active Cursor connection into an access token, if any. */
|
|
23
|
+
export declare function resolveCursorAccessToken(integration: IntegrationDomain): Promise<string | undefined>;
|