@evanovation/open-cursor 2.4.15

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 (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Non-blocking model auto-refresh for plugin startup.
3
+ *
4
+ * Discovers currently available models via the SDK runner and merges them
5
+ * into the opencode.json config. Only adds new models — never removes
6
+ * user-configured ones. Safe to call fire-and-forget; all errors are
7
+ * caught and logged silently.
8
+ */
9
+ import {
10
+ existsSync as nodeExistsSync,
11
+ readFileSync as nodeReadFileSync,
12
+ writeFileSync as nodeWriteFileSync,
13
+ } from "node:fs";
14
+ import { listModelsViaRunner } from "../client/sdk-child.js";
15
+ import { resolveSdkApiKey } from "../auth.js";
16
+ import { resolveOpenCodeConfigPath } from "../plugin-toggle.js";
17
+ import { createLogger, type Logger } from "../utils/logger.js";
18
+ import { mergeCursorModelEntries } from "./variants.js";
19
+
20
+ const log = createLogger("model-sync");
21
+ const PROVIDER_ID = "cursor-acp";
22
+
23
+ type AutoRefreshMode = "disabled" | "direct" | "compact";
24
+ type ModelConfigEntry = { name: string };
25
+ type ProviderConfig = { models?: Record<string, unknown> } & Record<string, unknown>;
26
+ type OpenCodeConfig = {
27
+ provider?: Record<string, ProviderConfig | undefined>;
28
+ } & Record<string, unknown>;
29
+
30
+ export type DiscoveredModel = {
31
+ id: string;
32
+ name: string;
33
+ };
34
+
35
+ type AutoRefreshModelsDeps = {
36
+ defer: () => Promise<void>;
37
+ discoverModels: () => Promise<DiscoveredModel[]>;
38
+ env: NodeJS.ProcessEnv;
39
+ existsSync: (path: string) => boolean;
40
+ log: Logger;
41
+ readFileSync: (path: string, encoding: BufferEncoding) => string;
42
+ writeFileSync: (path: string, data: string, encoding: BufferEncoding) => void;
43
+ };
44
+
45
+ const defaultDeps: AutoRefreshModelsDeps = {
46
+ defer: () => Promise.resolve(),
47
+ discoverModels: async () => {
48
+ const apiKey = resolveSdkApiKey({ env: process.env });
49
+ if (!apiKey) {
50
+ throw new Error("CURSOR_API_KEY not set");
51
+ }
52
+ const models = await listModelsViaRunner(apiKey);
53
+ return models.map((m) => ({ id: m.id, name: m.name }));
54
+ },
55
+ env: process.env,
56
+ existsSync: nodeExistsSync,
57
+ log,
58
+ readFileSync: nodeReadFileSync,
59
+ writeFileSync: nodeWriteFileSync,
60
+ };
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function parseConfig(raw: string): OpenCodeConfig | null {
67
+ try {
68
+ const parsed = JSON.parse(raw);
69
+ return isRecord(parsed) ? (parsed as OpenCodeConfig) : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function getProviderConfig(config: OpenCodeConfig): ProviderConfig | null {
76
+ if (!isRecord(config.provider)) {
77
+ return null;
78
+ }
79
+
80
+ const provider = config.provider[PROVIDER_ID];
81
+ return isRecord(provider) ? (provider as ProviderConfig) : null;
82
+ }
83
+
84
+ function getExistingModels(provider: ProviderConfig): Record<string, unknown> {
85
+ return isRecord(provider.models) ? { ...provider.models } : {};
86
+ }
87
+
88
+ function readCursorModel(value: unknown): string | undefined {
89
+ if (!isRecord(value)) return undefined;
90
+ const cursorModel = value.cursorModel;
91
+ return typeof cursorModel === "string" && cursorModel.trim().length > 0
92
+ ? cursorModel.trim()
93
+ : undefined;
94
+ }
95
+
96
+ function collectRepresentedModelIds(models: Record<string, unknown>): Set<string> {
97
+ const represented = new Set<string>(Object.keys(models));
98
+
99
+ for (const entry of Object.values(models)) {
100
+ if (!isRecord(entry)) continue;
101
+ const optionModel = readCursorModel(entry.options);
102
+ if (optionModel) represented.add(optionModel);
103
+
104
+ if (!isRecord(entry.variants)) continue;
105
+ for (const variantEntry of Object.values(entry.variants)) {
106
+ const variantModel = readCursorModel(variantEntry);
107
+ if (variantModel) represented.add(variantModel);
108
+ }
109
+ }
110
+
111
+ return represented;
112
+ }
113
+
114
+ function yieldForFireAndForget(): Promise<void> {
115
+ return Promise.resolve();
116
+ }
117
+
118
+ function getAutoRefreshMode(env: NodeJS.ProcessEnv): AutoRefreshMode {
119
+ const raw = env.CURSOR_ACP_MODEL_AUTO_REFRESH?.trim().toLowerCase();
120
+ if (raw === "false") return "disabled";
121
+ if (raw === "compact") return "compact";
122
+ return "direct";
123
+ }
124
+
125
+ /**
126
+ * Auto-refresh models at plugin startup.
127
+ *
128
+ * - Reads the current opencode.json config
129
+ * - Queries the SDK runner for available models
130
+ * - Merges discovered models into the provider config
131
+ * - Writes back if new models were added or compacted
132
+ *
133
+ * This function never throws. All failures are logged at debug level
134
+ * and silently ignored so plugin startup is never blocked.
135
+ */
136
+ export async function autoRefreshModels(
137
+ deps: Partial<AutoRefreshModelsDeps> = {},
138
+ ): Promise<void> {
139
+ const resolvedDeps: AutoRefreshModelsDeps = {
140
+ ...defaultDeps,
141
+ defer: yieldForFireAndForget,
142
+ ...deps,
143
+ };
144
+
145
+ await resolvedDeps.defer();
146
+
147
+ try {
148
+ const refreshMode = getAutoRefreshMode(resolvedDeps.env);
149
+ if (refreshMode === "disabled") {
150
+ resolvedDeps.log.debug("Model auto-refresh disabled by CURSOR_ACP_MODEL_AUTO_REFRESH");
151
+ return;
152
+ }
153
+
154
+ const configPath = resolveOpenCodeConfigPath(resolvedDeps.env);
155
+ if (!resolvedDeps.existsSync(configPath)) {
156
+ resolvedDeps.log.debug("Config file not found, skipping model auto-refresh", { configPath });
157
+ return;
158
+ }
159
+
160
+ const raw = resolvedDeps.readFileSync(configPath, "utf8");
161
+ const config = parseConfig(raw);
162
+ if (!config) {
163
+ resolvedDeps.log.debug("Config file is not valid JSON, skipping model auto-refresh");
164
+ return;
165
+ }
166
+
167
+ const provider = getProviderConfig(config);
168
+ if (!provider) {
169
+ resolvedDeps.log.debug("Provider section not found in config, skipping model auto-refresh");
170
+ return;
171
+ }
172
+
173
+ const existingModels = getExistingModels(provider);
174
+ let discovered: DiscoveredModel[];
175
+ try {
176
+ discovered = await resolvedDeps.discoverModels();
177
+ } catch (err) {
178
+ resolvedDeps.log.debug("Model discovery failed, skipping auto-refresh", {
179
+ error: String(err),
180
+ });
181
+ return;
182
+ }
183
+
184
+ if (refreshMode === "compact") {
185
+ const representedModelIds = collectRepresentedModelIds(existingModels);
186
+ const missingModels = discovered.filter(model => !representedModelIds.has(model.id));
187
+ const result = mergeCursorModelEntries(existingModels, discovered, {
188
+ variants: true,
189
+ compact: true,
190
+ });
191
+
192
+ if (
193
+ missingModels.length === 0
194
+ && result.removedCount === 0
195
+ && modelsEqual(existingModels, result.models)
196
+ ) {
197
+ resolvedDeps.log.debug("Model auto-refresh: no new models found", {
198
+ existing: Object.keys(existingModels).length,
199
+ discovered: discovered.length,
200
+ });
201
+ return;
202
+ }
203
+
204
+ provider.models = result.models;
205
+ resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
206
+ resolvedDeps.log.info("Model auto-refresh: synced models", {
207
+ mode: refreshMode,
208
+ synced: result.syncedCount,
209
+ grouped: result.groupedCount,
210
+ removed: result.removedCount,
211
+ total: Object.keys(result.models).length,
212
+ });
213
+ return;
214
+ }
215
+
216
+ let addedCount = 0;
217
+ for (const model of discovered) {
218
+ if (Object.prototype.hasOwnProperty.call(existingModels, model.id)) continue;
219
+ existingModels[model.id] = { name: model.name } satisfies ModelConfigEntry;
220
+ addedCount++;
221
+ }
222
+
223
+ if (addedCount === 0) {
224
+ resolvedDeps.log.debug("Model auto-refresh: no new models found", {
225
+ existing: Object.keys(existingModels).length,
226
+ discovered: discovered.length,
227
+ });
228
+ return;
229
+ }
230
+
231
+ provider.models = existingModels;
232
+ resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
233
+ resolvedDeps.log.info("Model auto-refresh: added new models", {
234
+ added: addedCount,
235
+ total: Object.keys(existingModels).length,
236
+ });
237
+ } catch (err) {
238
+ resolvedDeps.log.debug("Model auto-refresh failed", { error: String(err) });
239
+ }
240
+ }
241
+
242
+ function modelsEqual(
243
+ left: Record<string, unknown>,
244
+ right: Record<string, unknown>,
245
+ ): boolean {
246
+ return JSON.stringify(left) === JSON.stringify(right);
247
+ }
@@ -0,0 +1,11 @@
1
+ export interface ModelInfo {
2
+ id: string;
3
+ name: string;
4
+ description?: string;
5
+ aliases?: string[];
6
+ }
7
+
8
+ export interface DiscoveryConfig {
9
+ cacheTTL?: number; // milliseconds
10
+ fallbackModels?: ModelInfo[];
11
+ }
@@ -0,0 +1,446 @@
1
+ import { getCursorModelCost, type OpenCodeModelCost } from "./pricing.js";
2
+
3
+ export type DiscoveredCursorModel = {
4
+ id: string;
5
+ name: string;
6
+ };
7
+
8
+ export type CursorModelVariant = {
9
+ baseId: string;
10
+ variant: string | null;
11
+ cursorModelId: string;
12
+ name: string;
13
+ };
14
+
15
+ export type CursorModelGroup = {
16
+ baseId: string;
17
+ name: string;
18
+ defaultCursorModelId: string;
19
+ variants: Record<string, string>;
20
+ members: CursorModelVariant[];
21
+ };
22
+
23
+ export type CursorModelGroups = {
24
+ groups: CursorModelGroup[];
25
+ direct: DiscoveredCursorModel[];
26
+ };
27
+
28
+ export type OpenCodeCursorModelEntry = {
29
+ name: string;
30
+ options?: {
31
+ cursorModel: string;
32
+ };
33
+ variants?: Record<string, { cursorModel: string; cost?: OpenCodeModelCost }>;
34
+ cost?: OpenCodeModelCost;
35
+ };
36
+
37
+ export type CursorModelMergeOptions = {
38
+ variants: boolean;
39
+ compact: boolean;
40
+ };
41
+
42
+ export type CursorModelMergeResult = {
43
+ models: Record<string, unknown>;
44
+ syncedCount: number;
45
+ groupedCount: number;
46
+ removedCount: number;
47
+ };
48
+
49
+ const DEFAULT_VARIANT_ORDER = [
50
+ null,
51
+ "medium",
52
+ "high",
53
+ "low",
54
+ "none",
55
+ "xhigh",
56
+ "max",
57
+ ];
58
+
59
+ const VARIANT_DISPLAY_ORDER = [
60
+ "none",
61
+ "low",
62
+ "low-fast",
63
+ "fast",
64
+ "medium",
65
+ "medium-fast",
66
+ "medium-thinking",
67
+ "high",
68
+ "high-fast",
69
+ "high-thinking",
70
+ "high-thinking-fast",
71
+ "xhigh",
72
+ "xhigh-fast",
73
+ "max",
74
+ "max-thinking",
75
+ "max-thinking-fast",
76
+ "thinking",
77
+ "thinking-low",
78
+ "thinking-medium",
79
+ "thinking-high",
80
+ "thinking-high-fast",
81
+ "thinking-xhigh",
82
+ "thinking-max",
83
+ "extra-high",
84
+ "spark-preview",
85
+ "spark-preview-low",
86
+ "spark-preview-medium",
87
+ "spark-preview-high",
88
+ "spark-preview-xhigh",
89
+ ];
90
+
91
+ function isSafeBaseId(baseId: string): boolean {
92
+ const parts = baseId.split("-").filter(Boolean);
93
+ if (parts.length < 2) return false;
94
+ if (baseId === "gpt-5") return false;
95
+ return true;
96
+ }
97
+
98
+ // Token-aligned hyphen-truncated prefixes, longest first, filtered through
99
+ // isSafeBaseId. Example: "gpt-5.3-codex-spark-preview-low" yields
100
+ // ["gpt-5.3-codex-spark-preview", "gpt-5.3-codex", "gpt-5.3"].
101
+ function generateBaseCandidates(modelId: string): string[] {
102
+ const tokens = modelId.split("-");
103
+ const candidates: string[] = [];
104
+ for (let i = tokens.length - 1; i >= 1; i--) {
105
+ const prefix = tokens.slice(0, i).join("-");
106
+ if (isSafeBaseId(prefix)) candidates.push(prefix);
107
+ }
108
+ return candidates;
109
+ }
110
+
111
+ type CandidateStat = { count: number; diversity: number };
112
+
113
+ // childCount(B) = number of models that have B as a strict token-prefix
114
+ // (model starts with `${B}-`). diversity = distinct first tokens after the
115
+ // prefix; used to prefer bases that fan out across multiple sibling families.
116
+ function computeStats(
117
+ candidate: string,
118
+ modelIds: readonly string[],
119
+ ): CandidateStat {
120
+ const prefix = `${candidate}-`;
121
+ const firstTokens = new Set<string>();
122
+ let count = 0;
123
+ for (const otherId of modelIds) {
124
+ if (!otherId.startsWith(prefix)) continue;
125
+ count++;
126
+ const firstToken = otherId.slice(prefix.length).split("-", 1)[0];
127
+ if (firstToken) firstTokens.add(firstToken);
128
+ }
129
+ return { count, diversity: firstTokens.size };
130
+ }
131
+
132
+ // Selection priority for the chosen base of a model:
133
+ // A. Shortest explicit base with >= 2 strict children. An explicit
134
+ // candidate that already heads its own family wins outright. Shortest
135
+ // wins so spark-preview-low folds under gpt-5.3-codex when both
136
+ // gpt-5.3-codex and gpt-5.3-codex-spark-preview are in the set.
137
+ // B. Best implicit base (any candidate with >= 2 strict children). Pick
138
+ // highest first-token diversity, breaking ties by longer base. Keeps
139
+ // claude-4.6-opus (fans out into high/max) from being shadowed by
140
+ // claude-4.6-opus-high (only thinking-fan-out) or by claude-4.6 (only
141
+ // opus-fan-out).
142
+ // C. Shortest explicit fallback regardless of childCount. Catches cases
143
+ // like composer-2-fast where the only candidate is explicit but has no
144
+ // other siblings to satisfy the >= 2 rule.
145
+ function chooseBase(
146
+ modelId: string,
147
+ knownModelIds: Set<string>,
148
+ modelIds: readonly string[],
149
+ ): string | null {
150
+ const candidates = generateBaseCandidates(modelId);
151
+ if (candidates.length === 0) return null;
152
+
153
+ const stats = new Map<string, CandidateStat>();
154
+ for (const candidate of candidates) {
155
+ stats.set(candidate, computeStats(candidate, modelIds));
156
+ }
157
+
158
+ let stepA: string | null = null;
159
+ for (const candidate of candidates) {
160
+ if (!knownModelIds.has(candidate)) continue;
161
+ const stat = stats.get(candidate);
162
+ if (!stat || stat.count < 2 || stat.diversity < 2) continue;
163
+ if (stepA === null || candidate.length < stepA.length) stepA = candidate;
164
+ }
165
+ if (stepA !== null) return stepA;
166
+
167
+ let stepB: { base: string; diversity: number } | null = null;
168
+ for (const candidate of candidates) {
169
+ const stat = stats.get(candidate);
170
+ if (!stat || stat.count < 2) continue;
171
+ if (
172
+ stepB === null ||
173
+ stat.diversity > stepB.diversity ||
174
+ (stat.diversity === stepB.diversity && candidate.length > stepB.base.length)
175
+ ) {
176
+ stepB = { base: candidate, diversity: stat.diversity };
177
+ }
178
+ }
179
+ if (stepB !== null) return stepB.base;
180
+
181
+ let stepC: string | null = null;
182
+ for (const candidate of candidates) {
183
+ if (!knownModelIds.has(candidate)) continue;
184
+ if (stepC === null || candidate.length < stepC.length) stepC = candidate;
185
+ }
186
+ return stepC;
187
+ }
188
+
189
+ function getDefaultMember(members: CursorModelVariant[]): CursorModelVariant {
190
+ for (const variant of DEFAULT_VARIANT_ORDER) {
191
+ const member = members.find(candidate => candidate.variant === variant);
192
+ if (member) return member;
193
+ }
194
+
195
+ return members[0];
196
+ }
197
+
198
+ function formatModelName(modelId: string): string {
199
+ return modelId
200
+ .split("-")
201
+ .map(part => {
202
+ if (part === "gpt") return "GPT";
203
+ if (part === "xhigh") return "XHigh";
204
+ return part.charAt(0).toUpperCase() + part.slice(1);
205
+ })
206
+ .join(" ");
207
+ }
208
+
209
+ function compareVariants(a: CursorModelVariant, b: CursorModelVariant): number {
210
+ if (a.variant === null) return -1;
211
+ if (b.variant === null) return 1;
212
+
213
+ const aIndex = VARIANT_DISPLAY_ORDER.indexOf(a.variant);
214
+ const bIndex = VARIANT_DISPLAY_ORDER.indexOf(b.variant);
215
+
216
+ if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
217
+ if (aIndex !== -1) return -1;
218
+ if (bIndex !== -1) return 1;
219
+ return a.variant.localeCompare(b.variant);
220
+ }
221
+
222
+ function createGroup(baseId: string, members: CursorModelVariant[]): CursorModelGroup {
223
+ const defaultMember = getDefaultMember(members);
224
+ const variants: Record<string, string> = {};
225
+
226
+ for (const member of [...members].sort(compareVariants)) {
227
+ if (member.variant) {
228
+ variants[member.variant] = member.cursorModelId;
229
+ }
230
+ }
231
+
232
+ return {
233
+ baseId,
234
+ name: defaultMember.variant === null ? defaultMember.name : formatModelName(baseId),
235
+ defaultCursorModelId: defaultMember.cursorModelId,
236
+ variants,
237
+ members,
238
+ };
239
+ }
240
+
241
+ export function groupCursorModels(models: DiscoveredCursorModel[]): CursorModelGroups {
242
+ const knownModelIds = new Set(models.map(model => model.id));
243
+ const modelIds = models.map(model => model.id);
244
+
245
+ const preferredBase = new Map<string, string>();
246
+ for (const model of models) {
247
+ const base = chooseBase(model.id, knownModelIds, modelIds);
248
+ if (base) preferredBase.set(model.id, base);
249
+ }
250
+
251
+ // A model that is itself chosen as a base by some other model joins its own
252
+ // group as variant=null instead of being absorbed into a (different) base.
253
+ // This preserves explicit-base semantics: e.g. gpt-5.3-codex stays the head
254
+ // of its group rather than being folded under gpt-5.3 just because chooseBase
255
+ // for gpt-5.3-codex would otherwise return gpt-5.3.
256
+ const baseSet = new Set<string>(preferredBase.values());
257
+
258
+ const groupMembers = new Map<string, CursorModelVariant[]>();
259
+ const groupOrder: string[] = [];
260
+
261
+ const recordMember = (baseId: string, member: CursorModelVariant): void => {
262
+ const existing = groupMembers.get(baseId);
263
+ if (existing) {
264
+ existing.push(member);
265
+ return;
266
+ }
267
+ groupMembers.set(baseId, [member]);
268
+ groupOrder.push(baseId);
269
+ };
270
+
271
+ for (const model of models) {
272
+ if (baseSet.has(model.id) && knownModelIds.has(model.id)) {
273
+ recordMember(model.id, {
274
+ baseId: model.id,
275
+ variant: null,
276
+ cursorModelId: model.id,
277
+ name: model.name,
278
+ });
279
+ continue;
280
+ }
281
+
282
+ const base = preferredBase.get(model.id);
283
+ if (!base) continue;
284
+
285
+ recordMember(base, {
286
+ baseId: base,
287
+ variant: model.id.slice(base.length + 1),
288
+ cursorModelId: model.id,
289
+ name: model.name,
290
+ });
291
+ }
292
+
293
+ const groupedIds = new Set<string>();
294
+ const groups: CursorModelGroup[] = [];
295
+
296
+ for (const baseId of groupOrder) {
297
+ const members = groupMembers.get(baseId);
298
+ if (!members || members.length < 2) continue;
299
+ groups.push(createGroup(baseId, members));
300
+ for (const member of members) groupedIds.add(member.cursorModelId);
301
+ }
302
+
303
+ const direct: DiscoveredCursorModel[] = [];
304
+ for (const model of models) {
305
+ if (groupedIds.has(model.id)) continue;
306
+ direct.push(model);
307
+ }
308
+
309
+ return { groups, direct };
310
+ }
311
+
312
+ export function createVariantModelEntries(models: DiscoveredCursorModel[]): {
313
+ entries: Record<string, OpenCodeCursorModelEntry>;
314
+ groupedModelIds: Set<string>;
315
+ } {
316
+ const { groups, direct } = groupCursorModels(models);
317
+ const entries: Record<string, OpenCodeCursorModelEntry> = {};
318
+ const groupedModelIds = new Set<string>();
319
+
320
+ for (const group of groups) {
321
+ const variants: Record<string, { cursorModel: string; cost?: OpenCodeModelCost }> = {};
322
+ for (const [variant, cursorModel] of Object.entries(group.variants)) {
323
+ const variantEntry: { cursorModel: string; cost?: OpenCodeModelCost } = { cursorModel };
324
+ const variantCost = getCursorModelCost(cursorModel);
325
+ if (variantCost) variantEntry.cost = variantCost;
326
+ variants[variant] = variantEntry;
327
+ }
328
+
329
+ const groupEntry: OpenCodeCursorModelEntry = {
330
+ name: group.name,
331
+ options: {
332
+ cursorModel: group.defaultCursorModelId,
333
+ },
334
+ variants,
335
+ };
336
+ const defaultCost = getCursorModelCost(group.defaultCursorModelId);
337
+ if (defaultCost) groupEntry.cost = defaultCost;
338
+ entries[group.baseId] = groupEntry;
339
+
340
+ for (const member of group.members) {
341
+ groupedModelIds.add(member.cursorModelId);
342
+ }
343
+ }
344
+
345
+ for (const model of direct) {
346
+ const entry: OpenCodeCursorModelEntry = { name: model.name };
347
+ const directCost = getCursorModelCost(model.id);
348
+ if (directCost) entry.cost = directCost;
349
+ entries[model.id] = entry;
350
+ }
351
+
352
+ return { entries, groupedModelIds };
353
+ }
354
+
355
+ export function mergeCursorModelEntries(
356
+ existingModels: Record<string, unknown>,
357
+ discoveredModels: DiscoveredCursorModel[],
358
+ options: CursorModelMergeOptions,
359
+ ): CursorModelMergeResult {
360
+ if (!options.variants) {
361
+ return mergeDirectModelEntries(existingModels, discoveredModels);
362
+ }
363
+
364
+ const { entries, groupedModelIds } = createVariantModelEntries(discoveredModels);
365
+ const models = { ...existingModels };
366
+ let removedCount = 0;
367
+
368
+ if (options.compact) {
369
+ for (const modelId of groupedModelIds) {
370
+ if (!Object.prototype.hasOwnProperty.call(models, modelId)) continue;
371
+ if (Object.prototype.hasOwnProperty.call(entries, modelId)) continue;
372
+ delete models[modelId];
373
+ removedCount++;
374
+ }
375
+ }
376
+
377
+ for (const [modelId, entry] of Object.entries(entries)) {
378
+ models[modelId] = mergeEntryPreservingUserFields(models[modelId], entry);
379
+ }
380
+
381
+ return {
382
+ models,
383
+ syncedCount: Object.keys(entries).length,
384
+ groupedCount: groupedModelIds.size,
385
+ removedCount,
386
+ };
387
+ }
388
+
389
+ function mergeDirectModelEntries(
390
+ existingModels: Record<string, unknown>,
391
+ discoveredModels: DiscoveredCursorModel[],
392
+ ): CursorModelMergeResult {
393
+ const models = { ...existingModels };
394
+
395
+ for (const model of discoveredModels) {
396
+ const generated: OpenCodeCursorModelEntry = { name: model.name };
397
+ const directCost = getCursorModelCost(model.id);
398
+ if (directCost) generated.cost = directCost;
399
+ models[model.id] = mergeEntryPreservingUserFields(models[model.id], generated);
400
+ }
401
+
402
+ return {
403
+ models,
404
+ syncedCount: discoveredModels.length,
405
+ groupedCount: 0,
406
+ removedCount: 0,
407
+ };
408
+ }
409
+
410
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
411
+ return typeof value === "object" && value !== null && !Array.isArray(value);
412
+ }
413
+
414
+ // Preserve user-set cost on every sync. Only fill cost when the user has not.
415
+ function mergeEntryPreservingUserFields(
416
+ existing: unknown,
417
+ generated: OpenCodeCursorModelEntry,
418
+ ): OpenCodeCursorModelEntry {
419
+ if (!isPlainObject(existing)) return generated;
420
+
421
+ const merged: Record<string, unknown> = { ...existing, ...generated };
422
+
423
+ if (existing.cost !== undefined) {
424
+ merged.cost = existing.cost;
425
+ }
426
+
427
+ if (isPlainObject(existing.variants) && isPlainObject(generated.variants)) {
428
+ const mergedVariants: Record<string, unknown> = { ...generated.variants };
429
+ for (const [variantKey, existingVariant] of Object.entries(existing.variants)) {
430
+ const generatedVariant = (generated.variants as Record<string, unknown>)[variantKey];
431
+ if (!isPlainObject(existingVariant)) continue;
432
+ if (!isPlainObject(generatedVariant)) {
433
+ mergedVariants[variantKey] = existingVariant;
434
+ continue;
435
+ }
436
+ const variantMerged: Record<string, unknown> = { ...generatedVariant };
437
+ if (existingVariant.cost !== undefined) {
438
+ variantMerged.cost = existingVariant.cost;
439
+ }
440
+ mergedVariants[variantKey] = variantMerged;
441
+ }
442
+ merged.variants = mergedVariants;
443
+ }
444
+
445
+ return merged as OpenCodeCursorModelEntry;
446
+ }