cursor-opencode-provider 0.8.1 → 0.8.3

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -0,0 +1,39 @@
1
+ export type LostRequest = {
2
+ conversationId: string;
3
+ checkpointKey: string;
4
+ /** Everything Cursor has not checkpointed, as the last attempt sent it. */
5
+ text: string;
6
+ /** That attempt's own live text, before anything was prepended. */
7
+ latest: string;
8
+ };
9
+ export declare function checkpointKey(bytes: Uint8Array | undefined): string;
10
+ /**
11
+ * Prepend a user message whose Run never received a newer checkpoint.
12
+ * A history rebuild already contains it, so that path drops the record.
13
+ * A retry is the previous attempt's live text again: it sends what that
14
+ * attempt sent. A host that sends the same note before every attempt must not
15
+ * pile it up. Any other text is a new request and is appended, even when it
16
+ * repeats an older one (red, blue, then red again).
17
+ */
18
+ export declare function carryLostRequest(input: {
19
+ sessionKey: string;
20
+ conversationId: string;
21
+ checkpoint: Uint8Array | undefined;
22
+ text: string;
23
+ rebuildFromHistory: boolean;
24
+ }): string;
25
+ /** `text` is what this turn sends; `latest` is its own live text (defaults to `text`). */
26
+ export declare function rememberLostRequest(input: {
27
+ sessionKey: string;
28
+ conversationId: string;
29
+ checkpoint: Uint8Array | undefined;
30
+ text: string;
31
+ latest?: string;
32
+ }): void;
33
+ /** A checkpoint other than the one this turn was sent on means Cursor kept the turn. */
34
+ export declare function noteCheckpoint(sessionKey: string | undefined, bytes: Uint8Array): void;
35
+ export declare function hasLostRequest(sessionKey: string | undefined): boolean;
36
+ export declare function dropLostRequest(sessionKey: string | undefined): void;
37
+ export declare function serializeLostRequest(sessionKey: string | undefined): string | undefined;
38
+ export declare function restoreLostRequest(sessionKey: string, raw: string | undefined): void;
39
+ export declare function resetLostRequestsForTests(): void;
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import { processShared } from "./process-shared.js";
3
+ const MAX_RECORDS = 1_024;
4
+ const records = () => processShared("lost-request.v2", () => new Map());
5
+ export function checkpointKey(bytes) {
6
+ if (!bytes?.length)
7
+ return "none";
8
+ return createHash("sha256").update(bytes).digest("hex");
9
+ }
10
+ /**
11
+ * Prepend a user message whose Run never received a newer checkpoint.
12
+ * A history rebuild already contains it, so that path drops the record.
13
+ * A retry is the previous attempt's live text again: it sends what that
14
+ * attempt sent. A host that sends the same note before every attempt must not
15
+ * pile it up. Any other text is a new request and is appended, even when it
16
+ * repeats an older one (red, blue, then red again).
17
+ */
18
+ export function carryLostRequest(input) {
19
+ const map = records();
20
+ const record = map.get(input.sessionKey);
21
+ if (!record)
22
+ return input.text;
23
+ const key = checkpointKey(input.checkpoint);
24
+ if (input.rebuildFromHistory
25
+ || record.conversationId !== input.conversationId
26
+ || record.checkpointKey !== key) {
27
+ map.delete(input.sessionKey);
28
+ return input.text;
29
+ }
30
+ if (input.text === record.latest)
31
+ return record.text;
32
+ // The host already resends the lost text ahead of this one (OpenCode 1.x
33
+ // merges user messages left without a reply into one live turn).
34
+ if (input.text === record.text || input.text.startsWith(`${record.text}\n\n`))
35
+ return input.text;
36
+ map.delete(input.sessionKey);
37
+ return `${record.text}\n\n${input.text}`;
38
+ }
39
+ /** `text` is what this turn sends; `latest` is its own live text (defaults to `text`). */
40
+ export function rememberLostRequest(input) {
41
+ if (!input.sessionKey || !input.text || input.text === ".")
42
+ return;
43
+ setRecord(input.sessionKey, {
44
+ conversationId: input.conversationId,
45
+ checkpointKey: checkpointKey(input.checkpoint),
46
+ text: input.text,
47
+ latest: input.latest ?? input.text,
48
+ });
49
+ }
50
+ /** A session that never sends another turn must not keep its record forever. */
51
+ function setRecord(sessionKey, record) {
52
+ const map = records();
53
+ map.delete(sessionKey);
54
+ map.set(sessionKey, record);
55
+ while (map.size > MAX_RECORDS) {
56
+ const oldest = map.keys().next().value;
57
+ if (oldest === undefined)
58
+ break;
59
+ map.delete(oldest);
60
+ }
61
+ }
62
+ /** A checkpoint other than the one this turn was sent on means Cursor kept the turn. */
63
+ export function noteCheckpoint(sessionKey, bytes) {
64
+ if (!sessionKey)
65
+ return;
66
+ const map = records();
67
+ const record = map.get(sessionKey);
68
+ if (!record)
69
+ return;
70
+ if (checkpointKey(bytes) !== record.checkpointKey)
71
+ map.delete(sessionKey);
72
+ }
73
+ export function hasLostRequest(sessionKey) {
74
+ return !!sessionKey && records().has(sessionKey);
75
+ }
76
+ export function dropLostRequest(sessionKey) {
77
+ if (sessionKey)
78
+ records().delete(sessionKey);
79
+ }
80
+ export function serializeLostRequest(sessionKey) {
81
+ if (!sessionKey)
82
+ return undefined;
83
+ const record = records().get(sessionKey);
84
+ return record ? JSON.stringify(record) : undefined;
85
+ }
86
+ export function restoreLostRequest(sessionKey, raw) {
87
+ if (!raw || records().has(sessionKey))
88
+ return;
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(raw);
92
+ }
93
+ catch {
94
+ return;
95
+ }
96
+ if (typeof parsed.conversationId !== "string"
97
+ || typeof parsed.checkpointKey !== "string"
98
+ || typeof parsed.text !== "string"
99
+ || parsed.text.length === 0
100
+ || (parsed.latest !== undefined && typeof parsed.latest !== "string"))
101
+ return;
102
+ setRecord(sessionKey, {
103
+ conversationId: parsed.conversationId,
104
+ checkpointKey: parsed.checkpointKey,
105
+ text: parsed.text,
106
+ latest: parsed.latest ?? parsed.text,
107
+ });
108
+ }
109
+ export function resetLostRequestsForTests() {
110
+ records().clear();
111
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ModelInfo, type ModelVariant } from "./models.js";
2
+ type ContextTier = "base" | "long" | number;
2
3
  /**
3
4
  * Display names shared by both a thinking and a non-thinking model. A thinking
4
5
  * model with such a name needs a "Thinking" tag to disambiguate it from its
@@ -19,8 +20,9 @@ export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string
19
20
  export declare function cursorModelFamily(id: string): string | undefined;
20
21
  export declare function modelInfoToConfig(mi: ModelInfo, options?: {
21
22
  thinkingSuffix?: boolean;
22
- contextTier?: "base" | "long";
23
+ contextTier?: ContextTier;
23
24
  variants?: ModelVariant[];
24
25
  fast?: boolean;
25
26
  }): Record<string, any>;
26
27
  export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
28
+ export {};
@@ -1,4 +1,4 @@
1
- import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorContextLimit, } from "./models.js";
1
+ import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, findDefaultVariant, parseCursorContextLimit, } from "./models.js";
2
2
  import { applyCursorModelCost, hasCursorFastPricing } from "./pricing.js";
3
3
  import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages, } from "./model-metadata.js";
4
4
  /**
@@ -112,8 +112,23 @@ function isLongContextVariant(v) {
112
112
  function isFastVariant(v) {
113
113
  return v.parameterValues.some((p) => p.id === "fast" && p.value === "true");
114
114
  }
115
+ function variantContext(v) {
116
+ return parseCursorContextLimit(v.parameterValues.find((p) => p.id === "context")?.value);
117
+ }
118
+ function isSeparateContext(mi, v) {
119
+ const context = variantContext(v);
120
+ return isLongContextVariant(v) ||
121
+ (v.isMaxMode === true && context !== undefined && context !== mi.maxContext);
122
+ }
123
+ function contextSuffix(context) {
124
+ return context % 1_000_000 === 0 ? `${context / 1_000_000}m` : `${context / 1_000}k`;
125
+ }
115
126
  function variantsForTier(mi, tier) {
116
- return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
127
+ return mi.variants.filter((v) => tier === "base"
128
+ ? !isSeparateContext(mi, v)
129
+ : tier === "long"
130
+ ? isLongContextVariant(v)
131
+ : isSeparateContext(mi, v) && variantContext(v) === tier);
117
132
  }
118
133
  /**
119
134
  * Display names shared by both a thinking and a non-thinking model. A thinking
@@ -171,6 +186,8 @@ export function modelInfoToConfig(mi, options = {}) {
171
186
  name += " Fast";
172
187
  if (contextTier === "long")
173
188
  name += " 1M";
189
+ if (typeof contextTier === "number")
190
+ name += ` ${contextSuffix(contextTier).toUpperCase()}`;
174
191
  // OpenCode's context limit is static per model entry, while Cursor's context
175
192
  // tier is a variant parameter. Long-context choices are therefore emitted as
176
193
  // separate OpenCode entries by modelsToConfig.
@@ -179,9 +196,12 @@ export function modelInfoToConfig(mi, options = {}) {
179
196
  // live checkpoints report 256k for the base tier — the previous 200k
180
197
  // fallback made the host context meter disagree with Cursor.
181
198
  const fallbackContext = mi.id === "default" ? 256_000 : 200_000;
182
- const context = contextTier === "long"
183
- ? (mi.maxContextForMaxMode ?? documentedContext?.maxContextForMaxMode ?? 1_000_000)
184
- : (mi.maxContext ?? documentedContext?.maxContext ?? fallbackContext);
199
+ const context = typeof contextTier === "number"
200
+ ? contextTier
201
+ : contextTier === "long"
202
+ ? (variants.some(isLongContextVariant) ? 1_000_000
203
+ : (mi.maxContextForMaxMode ?? documentedContext?.maxContextForMaxMode ?? 1_000_000))
204
+ : (mi.maxContext ?? documentedContext?.maxContext ?? fallbackContext);
185
205
  // OpenCode's overflow/compaction/UI use limit.context; generation and
186
206
  // thinking budgets use limit.output. models.dev 1M peers advertise
187
207
  // 64k–128k output — a tiny cap makes long-context sessions feel broken
@@ -212,10 +232,8 @@ export function modelInfoToConfig(mi, options = {}) {
212
232
  const variantConfig = modelInfoVariants(mi, variants);
213
233
  if (variantConfig)
214
234
  config.variants = variantConfig;
215
- if (contextTier === "long" || options.fast) {
216
- const defaultVariant = contextTier === "long"
217
- ? (variants.find((v) => v.isDefaultMax) ?? variants[0])
218
- : (variants.find((v) => v.isDefaultNonMax) ?? variants[0]);
235
+ if (contextTier !== "base" || options.fast) {
236
+ const defaultVariant = findDefaultVariant(mi, variants, contextTier !== "base") ?? variants[0];
219
237
  if (defaultVariant) {
220
238
  config.options = {
221
239
  [CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
@@ -245,11 +263,9 @@ function uniqueCatalogId(usedIds, modelId, suffix) {
245
263
  }
246
264
  let n = 2;
247
265
  while (true) {
248
- const candidate = suffix === "-fast"
249
- ? `${modelId}-fast-${n}`
250
- : suffix === "-1m-fast"
251
- ? `${modelId}-1m-${n}-fast`
252
- : `${modelId}-1m-${n}`;
266
+ const candidate = suffix !== "-fast" && suffix.endsWith("-fast")
267
+ ? `${modelId}${suffix.slice(0, -5)}-${n}-fast`
268
+ : `${modelId}${suffix}-${n}`;
253
269
  if (!usedIds.has(candidate)) {
254
270
  usedIds.add(candidate);
255
271
  return candidate;
@@ -265,13 +281,28 @@ export function modelsToConfig(models) {
265
281
  const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
266
282
  const splitFast = hasCursorFastPricing(m.id);
267
283
  const groups = [];
268
- for (const contextTier of ["base", "long"]) {
269
- const suffixFor = (fast) => contextTier === "long" ? (fast ? "-1m-fast" : "-1m") : (fast ? "-fast" : "");
270
- for (const group of speedGroups(variantsForTier(m, contextTier), splitFast)) {
284
+ const extraContexts = new Set(m.variants
285
+ .filter((v) => isSeparateContext(m, v) && !isLongContextVariant(v))
286
+ .map(variantContext)
287
+ .filter((context) => context !== undefined));
288
+ const tiers = ["base", "long", ...extraContexts];
289
+ for (const contextTier of tiers) {
290
+ const tierSuffix = contextTier === "base" ? ""
291
+ : contextTier === "long" ? "-1m" : `-${contextSuffix(contextTier)}`;
292
+ const suffixFor = (fast) => `${tierSuffix}${fast ? "-fast" : ""}`;
293
+ const tierVariants = new Set(variantsForTier(m, contextTier));
294
+ // Base/Fast ids and labels are persisted by the host. Keep their pre-split
295
+ // non-1M variants available while dedicated entries supply accurate limits.
296
+ const advertised = contextTier === "base"
297
+ ? m.variants.filter((variant) => !isLongContextVariant(variant))
298
+ : [...tierVariants];
299
+ for (const group of speedGroups(advertised, splitFast)) {
300
+ const tierGroup = group.variants.filter((variant) => tierVariants.has(variant));
271
301
  groups.push({
272
302
  contextTier,
273
303
  fast: group.fast,
274
- variants: group.variants,
304
+ variants: tierGroup.length > 0 ? tierGroup : group.variants,
305
+ advertisedVariants: group.variants,
275
306
  suffix: suffixFor(group.fast),
276
307
  });
277
308
  }
@@ -293,17 +324,7 @@ export function modelsToConfig(models) {
293
324
  variants: group.variants,
294
325
  fast: group.fast,
295
326
  });
296
- if (catalogId !== m.id && !config.options) {
297
- const defaultVariant = (group.contextTier === "long"
298
- ? group.variants.find((v) => v.isDefaultMax)
299
- : group.variants.find((v) => v.isDefaultNonMax)) ?? group.variants[0];
300
- if (defaultVariant) {
301
- config.options = {
302
- [CURSOR_WIRE_MODEL_ID_KEY]: m.id,
303
- [CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
304
- };
305
- }
306
- }
327
+ config.variants = modelInfoVariants(m, group.advertisedVariants);
307
328
  out[catalogId] = applyCursorModelCost(pricingId, config);
308
329
  }
309
330
  }
package/dist/models.d.ts CHANGED
@@ -12,6 +12,11 @@ export type ModelVariant = {
12
12
  displayName: string;
13
13
  isDefaultNonMax: boolean;
14
14
  isDefaultMax: boolean;
15
+ /**
16
+ * Cursor's `AvailableModelVariant.is_max_mode`: this tuple runs only with
17
+ * request-level max mode. Absent when discovery omits the marker.
18
+ */
19
+ isMaxMode?: boolean;
15
20
  };
16
21
  export type ModelInfo = {
17
22
  id: string;
@@ -24,6 +29,11 @@ export type ModelInfo = {
24
29
  /** Context window when max-mode is on (proto field 16). */
25
30
  maxContextForMaxMode?: number;
26
31
  supportsMaxMode?: boolean;
32
+ /**
33
+ * `AvailableModel.supports_non_max_mode`; `false` means every variant runs
34
+ * in max mode. Absent when discovery omits it.
35
+ */
36
+ supportsNonMaxMode?: boolean;
27
37
  variants: ModelVariant[];
28
38
  };
29
39
  export type ModelCache = {
@@ -45,17 +55,30 @@ export declare function normalizeModelCache(value: unknown): ModelCache | null;
45
55
  */
46
56
  export declare function extractCursorVariantParameters(providerOptions: Record<string, unknown> | undefined): ModelParameterValue[] | undefined;
47
57
  export declare function resolveCursorWireModelId(providerOptions: Record<string, unknown> | undefined, fallback: string): string;
48
- /** True when resolved params select the long-context (1m) tier. */
58
+ /**
59
+ * True when resolved params select the long-context (1m) tier. Fallback for
60
+ * variants without Cursor's `isMaxMode` marker.
61
+ */
49
62
  export declare function paramsImplyMaxMode(params: ModelParameterValue[]): boolean;
50
63
  /**
51
- * Resolve the request-level max_mode bit. An explicit variant pick owns the
52
- * context tier; otherwise a standalone maxMode hint may select max mode.
64
+ * Resolve the request-level max_mode bit. Like Cursor CLI, a cataloged variant
65
+ * requires max mode when Cursor marks it `isMaxMode` (Grok 4.7's 500k tier as
66
+ * well as 1m tiers) or the model does not support non-max mode. An explicit
67
+ * variant pick owns the context tier; otherwise a standalone maxMode hint may
68
+ * select max mode.
53
69
  */
54
70
  export declare function resolveVariantMaxMode(params: ModelParameterValue[], opts?: {
55
71
  picked?: ModelParameterValue[];
56
72
  maxMode?: boolean;
73
+ model?: ModelInfo;
57
74
  }): boolean;
58
75
  export declare function parseCursorContextLimit(value: unknown): number | undefined;
76
+ /**
77
+ * Find Cursor's declared default within a catalog subset, retaining its effort:
78
+ * the declared variant, else its speed twin, else its twin in another context
79
+ * tier (a 500k entry when the declared max default is 1m).
80
+ */
81
+ export declare function findDefaultVariant(model: ModelInfo, candidates: ModelVariant[], maxMode: boolean): ModelVariant | undefined;
59
82
  export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
60
83
  export declare function cacheFilePath(cacheDir: string): string;
61
84
  export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
package/dist/models.js CHANGED
@@ -1,6 +1,5 @@
1
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
- import { buildRequestedModelParams } from "./protocol/thinking.js";
4
3
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
5
4
  import path from "node:path";
6
5
  /** Dedicated OpenCode provider-option key for a selected Cursor variant. */
@@ -59,11 +58,15 @@ export function normalizeModelParameterValues(value) {
59
58
  if (!Array.isArray(value))
60
59
  return null;
61
60
  const parameters = [];
61
+ const ids = new Set();
62
62
  for (const parameter of value) {
63
63
  if (!isPlainRecord(parameter))
64
64
  return null;
65
65
  if (!isNonEmptyString(parameter.id) || typeof parameter.value !== "string")
66
66
  return null;
67
+ if (ids.has(parameter.id))
68
+ return null;
69
+ ids.add(parameter.id);
67
70
  parameters.push({ id: parameter.id, value: parameter.value });
68
71
  }
69
72
  return parameters;
@@ -78,10 +81,12 @@ function normalizeVariant(value, fallbackName) {
78
81
  const displayName = optionalString(value, ["displayName", "display_name"]);
79
82
  const isDefaultNonMax = optionalBoolean(value, ["isDefaultNonMax", "isDefaultNonMaxConfig", "is_default_non_max_config"]);
80
83
  const isDefaultMax = optionalBoolean(value, ["isDefaultMax", "isDefaultMaxConfig", "is_default_max_config"]);
84
+ const isMaxMode = optionalBoolean(value, ["isMaxMode", "is_max_mode"]);
81
85
  if (key === null ||
82
86
  displayName === null ||
83
87
  isDefaultNonMax === null ||
84
- isDefaultMax === null) {
88
+ isDefaultMax === null ||
89
+ isMaxMode === null) {
85
90
  return null;
86
91
  }
87
92
  if ((key !== undefined && !isNonEmptyString(key)) ||
@@ -94,6 +99,7 @@ function normalizeVariant(value, fallbackName) {
94
99
  parameterValues: parameters,
95
100
  isDefaultNonMax: isDefaultNonMax ?? false,
96
101
  isDefaultMax: isDefaultMax ?? false,
102
+ ...(isMaxMode === undefined ? {} : { isMaxMode }),
97
103
  };
98
104
  }
99
105
  function normalizeModelInfo(value) {
@@ -111,6 +117,7 @@ function normalizeModelInfo(value) {
111
117
  const supportsAgent = optionalBoolean(value, ["supportsAgent", "supports_agent"]);
112
118
  const supportsImages = optionalBoolean(value, ["supportsImages", "supports_images"]);
113
119
  const supportsMaxMode = optionalBoolean(value, ["supportsMaxMode", "supports_max_mode"]);
120
+ const supportsNonMaxMode = optionalBoolean(value, ["supportsNonMaxMode", "supports_non_max_mode"]);
114
121
  const maxContext = optionalPositiveNumber(value, ["maxContext", "contextTokenLimit", "context_token_limit"]);
115
122
  const maxContextForMaxMode = optionalPositiveNumber(value, ["maxContextForMaxMode", "contextTokenLimitForMaxMode", "context_token_limit_for_max_mode"]);
116
123
  if (displayName === null ||
@@ -119,6 +126,7 @@ function normalizeModelInfo(value) {
119
126
  supportsAgent === null ||
120
127
  supportsImages === null ||
121
128
  supportsMaxMode === null ||
129
+ supportsNonMaxMode === null ||
122
130
  maxContext === null ||
123
131
  maxContextForMaxMode === null) {
124
132
  return null;
@@ -133,6 +141,7 @@ function normalizeModelInfo(value) {
133
141
  ...(maxContext === undefined ? {} : { maxContext }),
134
142
  ...(maxContextForMaxMode === undefined ? {} : { maxContextForMaxMode }),
135
143
  ...(supportsMaxMode === undefined ? {} : { supportsMaxMode }),
144
+ ...(supportsNonMaxMode === undefined ? {} : { supportsNonMaxMode }),
136
145
  variants: variants,
137
146
  };
138
147
  }
@@ -176,17 +185,37 @@ export function resolveCursorWireModelId(providerOptions, fallback) {
176
185
  const value = providerOptions?.[CURSOR_WIRE_MODEL_ID_KEY];
177
186
  return typeof value === "string" && value.trim() ? value : fallback;
178
187
  }
179
- /** True when resolved params select the long-context (1m) tier. */
188
+ /**
189
+ * True when resolved params select the long-context (1m) tier. Fallback for
190
+ * variants without Cursor's `isMaxMode` marker.
191
+ */
180
192
  export function paramsImplyMaxMode(params) {
181
193
  return params.some((parameter) => parameter.id === "context" &&
182
194
  parseCursorContextLimit(parameter.value) === 1_000_000);
183
195
  }
196
+ function sameParameterTuple(a, b) {
197
+ const byId = new Map(b.map((parameter) => [parameter.id, parameter.value]));
198
+ return (a.length === b.length &&
199
+ new Set(a.map((parameter) => parameter.id)).size === a.length &&
200
+ byId.size === b.length &&
201
+ a.every((parameter) => byId.get(parameter.id) === parameter.value));
202
+ }
203
+ /** Cursor's per-variant max-mode marker, else the 1m-context fallback. */
204
+ function variantIsMaxMode(v) {
205
+ return v.isMaxMode ?? paramsImplyMaxMode(v.parameterValues);
206
+ }
184
207
  /**
185
- * Resolve the request-level max_mode bit. An explicit variant pick owns the
186
- * context tier; otherwise a standalone maxMode hint may select max mode.
208
+ * Resolve the request-level max_mode bit. Like Cursor CLI, a cataloged variant
209
+ * requires max mode when Cursor marks it `isMaxMode` (Grok 4.7's 500k tier as
210
+ * well as 1m tiers) or the model does not support non-max mode. An explicit
211
+ * variant pick owns the context tier; otherwise a standalone maxMode hint may
212
+ * select max mode.
187
213
  */
188
214
  export function resolveVariantMaxMode(params, opts = {}) {
189
- return paramsImplyMaxMode(params) || (opts.picked === undefined && opts.maxMode === true);
215
+ if (opts.model?.supportsNonMaxMode === false)
216
+ return true;
217
+ const marked = opts.model?.variants.find((v) => sameParameterTuple(v.parameterValues, params))?.isMaxMode;
218
+ return (marked ?? paramsImplyMaxMode(params)) || (opts.picked === undefined && opts.maxMode === true);
190
219
  }
191
220
  // Cursor encodes a model's context window as a variant parameter `id: "context"`
192
221
  // whose value is a tier string — the same 200k / 272k / 300k / 1m the IDE's
@@ -212,8 +241,28 @@ function variantContextTokens(v) {
212
241
  const raw = v?.parameterValues.find((p) => p.id === "context")?.value;
213
242
  return parseCursorContextLimit(raw);
214
243
  }
215
- function isLongContextVariant(v) {
216
- return variantContextTokens(v) === 1_000_000;
244
+ /** Same parameters as `target` except the ignored ids. */
245
+ function isTwinOf(v, target, ignored) {
246
+ return (v.parameterValues.length === target.parameterValues.length &&
247
+ sameParameterTuple(v.parameterValues.filter((p) => !ignored.includes(p.id)), target.parameterValues.filter((p) => !ignored.includes(p.id))));
248
+ }
249
+ /**
250
+ * Find Cursor's declared default within a catalog subset, retaining its effort:
251
+ * the declared variant, else its speed twin, else its twin in another context
252
+ * tier (a 500k entry when the declared max default is 1m).
253
+ */
254
+ export function findDefaultVariant(model, candidates, maxMode) {
255
+ const isDeclared = (v) => maxMode ? v.isDefaultMax : v.isDefaultNonMax;
256
+ const declared = model.variants.find(isDeclared);
257
+ return candidates.find(isDeclared) ??
258
+ (declared === undefined
259
+ ? undefined
260
+ : candidates.find((v) => isTwinOf(v, declared, ["fast"])) ??
261
+ candidates.find((v) => isTwinOf(v, declared, ["fast", "context"])));
262
+ }
263
+ /** Send an advertised tuple verbatim; never rewrite one of its values. */
264
+ function copyParameters(parameters) {
265
+ return parameters.map((parameter) => ({ ...parameter }));
217
266
  }
218
267
  function positiveNumber(value) {
219
268
  return typeof value === "number" && Number.isFinite(value) && value > 0
@@ -286,27 +335,38 @@ export function mapAvailableModelsResponse(raw) {
286
335
  if (!parameterValues) {
287
336
  throw new Error(`AvailableModels returned invalid parameter values for ${name}`);
288
337
  }
338
+ const isMaxMode = optionalBoolean(v, ["isMaxMode", "is_max_mode"]);
339
+ if (isMaxMode === null) {
340
+ throw new Error(`AvailableModels returned a non-boolean isMaxMode for ${name}`);
341
+ }
289
342
  variants.push({
290
343
  key: name,
291
344
  parameterValues,
292
345
  displayName: (v.displayName ?? v.display_name ?? name),
293
346
  isDefaultNonMax: apiBoolean(v, ["isDefaultNonMaxConfig", "is_default_non_max_config"]),
294
347
  isDefaultMax: apiBoolean(v, ["isDefaultMaxConfig", "is_default_max_config"]),
348
+ ...(isMaxMode === undefined ? {} : { isMaxMode }),
295
349
  });
296
350
  }
297
351
  // Derive the context window from the variant `context` param (primary
298
352
  // source). Base tier ← default non-max variant; max tier ← default max
299
353
  // variant. Fall back to the (often empty) proto fields, then undefined.
300
- // Prefer a non-1m context when isDefaultNonMax is missing so a leading
354
+ // Prefer a non-max context when isDefaultNonMax is missing so a leading
301
355
  // max-mode variant cannot inflate the base window.
302
356
  const variantBaseContext = variantContextTokens(variants.find((v) => v.isDefaultNonMax)) ??
303
- variantContextTokens(variants.find((v) => variantContextTokens(v) !== 1_000_000)) ??
357
+ variantContextTokens(variants.find((v) => !variantIsMaxMode(v) && variantContextTokens(v) !== undefined &&
358
+ variantContextTokens(v) !== 1_000_000)) ??
304
359
  variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
305
- const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
360
+ const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax)) ??
361
+ variantContextTokens(variants.find((v) => variantIsMaxMode(v) && variantContextTokens(v) !== undefined));
306
362
  const supportsImages = optionalBoolean(e, ["supportsImages", "supports_images"]);
307
363
  if (supportsImages === null) {
308
364
  throw new Error(`AvailableModels returned invalid supportsImages for ${name}`);
309
365
  }
366
+ const supportsNonMaxMode = optionalBoolean(e, ["supportsNonMaxMode", "supports_non_max_mode"]);
367
+ if (supportsNonMaxMode === null) {
368
+ throw new Error(`AvailableModels returned invalid supportsNonMaxMode for ${name}`);
369
+ }
310
370
  models.push({
311
371
  id: name,
312
372
  displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
@@ -317,6 +377,7 @@ export function mapAvailableModelsResponse(raw) {
317
377
  maxContextForMaxMode: variantMaxContext ??
318
378
  positiveNumber(e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode),
319
379
  supportsMaxMode: apiBoolean(e, ["supportsMaxMode", "supports_max_mode"]),
380
+ ...(supportsNonMaxMode === undefined ? {} : { supportsNonMaxMode }),
320
381
  variants,
321
382
  });
322
383
  }
@@ -344,51 +405,46 @@ export function resolveVariantParameters(model, opts = {}) {
344
405
  }
345
406
  return opts.reasoningEffort ? [{ id: "effort", value: opts.reasoningEffort }] : [];
346
407
  }
347
- const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning")?.value;
408
+ const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning" || p.id === "reasoning_effort")?.value;
348
409
  const isFast = (v) => v.parameterValues.find((p) => p.id === "fast")?.value === "true";
349
- const isMaxVariant = (v) => v.isDefaultMax || isLongContextVariant(v);
350
- const wantMax = opts.maxMode ?? false;
351
- // Non-fast pool for hint-based resolution only. Exact `picked` matching
352
- // searches all variants so Fast selections are not silently dropped.
353
- const pool = model.variants.filter((v) => !isFast(v));
354
- const scoped = pool.length > 0 ? pool : model.variants;
410
+ const wantMax = (opts.maxMode ?? false) || model.supportsNonMaxMode === false;
355
411
  // 1. Variant explicitly picked by opencode (verbatim — preserves context, fast, …).
356
412
  if (picked !== undefined) {
357
- const pickedById = new Map(picked.map((parameter) => [parameter.id, parameter.value]));
358
- const exact = model.variants.find((v) => v.parameterValues.length === picked.length &&
359
- pickedById.size === picked.length &&
360
- v.parameterValues.every((parameter) => pickedById.get(parameter.id) === parameter.value));
413
+ const exact = model.variants.find((v) => sameParameterTuple(v.parameterValues, picked));
361
414
  if (exact) {
362
- return buildRequestedModelParams(exact.parameterValues);
415
+ return copyParameters(exact.parameterValues);
363
416
  }
364
417
  throw new CursorVariantSelectionError(`is stale for model ${JSON.stringify(model.id)}: its exact parameter tuple is unavailable`);
365
418
  }
366
- // When maxMode is requested, prefer max-tier variants for effort matching
367
- // so "high + max" lands on 1m high rather than the first 300k high.
368
- const maxScoped = wantMax ? scoped.filter(isMaxVariant) : [];
369
- const effortPool = maxScoped.length > 0 ? maxScoped : scoped;
370
- // 2. Effort hint (e.g. user passed reasoningEffort through the CLI).
419
+ // Choose the tier before preferring non-Fast variants. Otherwise a Fast-only
420
+ // max tier disappears, or a leading max variant steals an effort-only hint.
421
+ // isDefaultMax is a preference, not a requirement to run in max mode.
422
+ const tier = model.variants.filter((v) => variantIsMaxMode(v) === wantMax);
423
+ const candidates = tier.length > 0 ? tier : model.variants;
424
+ const nonFast = candidates.filter((v) => !isFast(v));
425
+ const scoped = nonFast.length > 0 ? nonFast : candidates;
426
+ // Cursor can declare a Fast variant as a default (Grok, GPT max defaults);
427
+ // the tier default retains the declared default's effort when its speed
428
+ // twin is needed (same parameters except `fast`).
429
+ const byDefault = (wantMax
430
+ ? findDefaultVariant(model, scoped, true) ?? scoped.find(variantIsMaxMode)
431
+ : undefined) ?? findDefaultVariant(model, scoped, false) ?? scoped[0];
432
+ // 2. Effort hint (e.g. user passed reasoningEffort through the CLI). Like
433
+ // Cursor CLI, break ties toward the tier default so the hint changes only
434
+ // effort, not thinking or other parameters (Claude lists thinking=false first).
371
435
  if (opts.reasoningEffort) {
372
- const match = effortPool.find((v) => effortOf(v) === opts.reasoningEffort);
436
+ const shared = (v) => v.parameterValues.filter((parameter) => byDefault.parameterValues.some((d) => d.id === parameter.id && d.value === parameter.value)).length;
437
+ const match = scoped
438
+ .filter((v) => effortOf(v) === opts.reasoningEffort)
439
+ .reduce((best, v) => best && shared(best) >= shared(v) ? best : v, undefined);
373
440
  if (match) {
374
- return buildRequestedModelParams(match.parameterValues, {
375
- reasoningEffort: opts.reasoningEffort,
376
- maxMode: wantMax,
377
- });
441
+ return copyParameters(match.parameterValues);
378
442
  }
379
443
  }
380
- // 3. Max-mode hint → prefer the default max variant (1m context).
381
- if (wantMax) {
382
- const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find(isLongContextVariant);
383
- if (max)
384
- return buildRequestedModelParams(max.parameterValues, { maxMode: true });
385
- }
386
- // 4. Default non-max variant.
387
- const byDefault = scoped.find((v) => v.isDefaultNonMax) ?? scoped[0];
388
- return buildRequestedModelParams(byDefault.parameterValues, {
389
- reasoningEffort: opts.reasoningEffort,
390
- maxMode: false,
391
- });
444
+ // 3. Max-mode hint → the default max variant (the max tier).
445
+ // 4. Otherwise the default non-max variant, else the first candidate.
446
+ // An unsupported effort must not synthesize a tuple Cursor never advertised.
447
+ return copyParameters(byDefault.parameterValues);
392
448
  }
393
449
  // ── Fetch + cache orchestration ──
394
450
  export async function fetchModels(token, options = {}) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Preserve native result status before OpenCode's AI SDK lowers errors to text.
3
+ * Upstream: https://github.com/anomalyco/opencode/blob/v2.0.27/packages/core/src/aisdk.ts#L652-L685
4
+ * Native part.providerMetadata becomes SDK part.providerOptions (same file, L717-L720).
5
+ */
6
+ export type ToolRefusal = "permission_denied" | "rejected";
7
+ export type NativeToolFailure = {
8
+ type: string;
9
+ text: string;
10
+ refusal?: ToolRefusal;
11
+ };
12
+ export declare const TOOL_RESULT_STATUS_KEY = "opencodeToolResult";
13
+ export declare function nativeToolFailure(value: unknown, refusal?: ToolRefusal): NativeToolFailure;
14
+ /** The native tool hook preserves the underlying typed permission cause. */
15
+ export declare function nativeToolRefusal(error: unknown): ToolRefusal | undefined;
16
+ /** Metadata travels with each part, including history and independently loaded SDK copies. */
17
+ export declare function preserveNativeToolResults(messages: readonly unknown[] | undefined, refusals?: ReadonlyMap<string, ToolRefusal>): any[];
18
+ export declare function toolResultFailure(options: unknown, callID: string): NativeToolFailure | undefined;