@rama_nigg/open-cursor 2.3.19 → 2.4.0

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.
@@ -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
+ }
@@ -33,6 +33,12 @@ export function isCursorPluginEnabledInConfig(config: unknown): boolean {
33
33
 
34
34
  const configObject = config as { plugin?: unknown; provider?: unknown };
35
35
 
36
+ if (configObject.provider && typeof configObject.provider === "object") {
37
+ if (CURSOR_PROVIDER_ID in (configObject.provider as Record<string, unknown>)) {
38
+ return true;
39
+ }
40
+ }
41
+
36
42
  if (Array.isArray(configObject.plugin)) {
37
43
  return configObject.plugin.some((entry) => matchesPlugin(entry));
38
44
  }
@@ -63,7 +69,7 @@ export function shouldEnableCursorPlugin(env: EnvLike = process.env): {
63
69
  return {
64
70
  enabled,
65
71
  configPath,
66
- reason: enabled ? "enabled_in_plugin_array_or_legacy" : "disabled_in_plugin_array",
72
+ reason: enabled ? "enabled" : "disabled_in_plugin_array",
67
73
  };
68
74
  } catch {
69
75
  return {