@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.
- package/LICENSE +28 -0
- package/README.md +270 -0
- package/dist/cli/discover.js +527 -0
- package/dist/cli/mcptool.js +10339 -0
- package/dist/cli/opencode-cursor.js +2989 -0
- package/dist/index.js +20588 -0
- package/dist/plugin-entry.js +19848 -0
- package/package.json +82 -0
- package/scripts/cursor-agent-runner.mjs +272 -0
- package/scripts/sdk-runner.mjs +412 -0
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +175 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/mcptool.ts +133 -0
- package/src/cli/model-discovery.ts +71 -0
- package/src/cli/opencode-cursor.ts +1195 -0
- package/src/client/cursor-agent-child.ts +459 -0
- package/src/client/sdk-child.ts +550 -0
- package/src/client/simple.ts +293 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +39 -0
- package/src/mcp/client-manager.ts +166 -0
- package/src/mcp/config.ts +169 -0
- package/src/mcp/tool-bridge.ts +133 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +105 -0
- package/src/models/index.ts +3 -0
- package/src/models/pricing.ts +196 -0
- package/src/models/sync.ts +247 -0
- package/src/models/types.ts +11 -0
- package/src/models/variants.ts +446 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +81 -0
- package/src/plugin.ts +2802 -0
- package/src/provider/backend.ts +71 -0
- package/src/provider/boundary.ts +168 -0
- package/src/provider/passthrough-tracker.ts +38 -0
- package/src/provider/runtime-interception.ts +818 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +800 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +60 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/incremental-prompt.ts +74 -0
- package/src/proxy/prompt-builder.ts +204 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/session-resume.ts +312 -0
- package/src/proxy/tool-loop.ts +359 -0
- package/src/proxy/types.ts +13 -0
- package/src/services/toast-service.ts +81 -0
- package/src/streaming/ai-sdk-parts.ts +109 -0
- package/src/streaming/delta-tracker.ts +89 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +118 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +158 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +954 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +59 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/usage.ts +112 -0
- package/src/utils/binary.ts +71 -0
- package/src/utils/errors.ts +224 -0
- package/src/utils/logger.ts +191 -0
- 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,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
|
+
}
|