@oh-my-pi/pi-catalog 18.4.4 → 18.4.6
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/CHANGELOG.md +31 -0
- package/dist/types/compat/auth-ids.d.ts +2 -2
- package/dist/types/compat/axes.d.ts +3 -1
- package/dist/types/compat/factory-droid.d.ts +65 -0
- package/dist/types/compat/provider-ids.d.ts +1 -1
- package/dist/types/compat/resolve.d.ts +8 -2
- package/dist/types/compat/types.d.ts +26 -1
- package/dist/types/discovery/cursor-proto.d.ts +284 -0
- package/dist/types/discovery/cursor.d.ts +12 -6
- package/dist/types/discovery/factory-droid.d.ts +28 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/discovery/protobuf.d.ts +3 -3
- package/dist/types/model-manager.d.ts +10 -0
- package/dist/types/models.d.ts +5 -1
- package/dist/types/provider-models/cache-provider-id.d.ts +2 -1
- package/dist/types/provider-models/descriptor-types.d.ts +2 -1
- package/dist/types/provider-models/openai-compat.d.ts +21 -0
- package/dist/types/provider-models/special.d.ts +2 -0
- package/dist/types/types.d.ts +100 -3
- package/dist/types/wire/cursor.d.ts +20 -0
- package/dist/types/wire/factory-droid.d.ts +45 -0
- package/package.json +4 -4
- package/src/build.ts +21 -0
- package/src/compat/auth-ids.ts +4 -0
- package/src/compat/axes.ts +113 -2
- package/src/compat/cascade.ts +8 -1
- package/src/compat/collapse.ts +3 -0
- package/src/compat/factory-droid.ts +170 -0
- package/src/compat/provider-ids.ts +2 -0
- package/src/compat/resolve.ts +47 -5
- package/src/compat/rules/README.md +18 -8
- package/src/compat/rules/auth/_order.kdl +2 -2
- package/src/compat/rules/auth/factory-droid.kdl +28 -0
- package/src/compat/rules/auth/helmcode.kdl +9 -0
- package/src/compat/rules/classes/anthropic.kdl +195 -80
- package/src/compat/rules/providers/commandcode.kdl +21 -5
- package/src/compat/rules/providers/cursor.kdl +29 -17
- package/src/compat/rules/providers/factory-droid.kdl +1166 -0
- package/src/compat/rules/providers/helmcode.kdl +120 -0
- package/src/compat/rules/runtime/behavior.kdl +65 -4
- package/src/compat/rules/taxonomy/_collapse.kdl +36 -14
- package/src/compat/rules.json +1 -1
- package/src/compat/types.ts +27 -1
- package/src/discovery/cursor-models.proto +261 -0
- package/src/discovery/cursor-proto.ts +471 -0
- package/src/discovery/cursor.ts +817 -88
- package/src/discovery/devin.ts +1 -3
- package/src/discovery/factory-droid.ts +296 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/protobuf.ts +16 -7
- package/src/model-manager.ts +13 -0
- package/src/models.json +1 -1
- package/src/models.ts +13 -0
- package/src/provider-models/cache-provider-id.ts +37 -6
- package/src/provider-models/descriptor-types.ts +2 -1
- package/src/provider-models/descriptors.ts +4 -0
- package/src/provider-models/openai-compat.ts +83 -17
- package/src/provider-models/special.ts +26 -2
- package/src/types.ts +112 -1
- package/src/wire/cursor.ts +32 -0
- package/src/wire/factory-droid.ts +130 -0
package/src/discovery/cursor.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import * as http2 from "node:http2";
|
|
2
2
|
import { type } from "@oh-my-pi/omptype";
|
|
3
|
+
import { collapseVariants, type EffortVariantFamily } from "../compat/collapse";
|
|
3
4
|
import { compareRevision, parseRevision } from "../compat/revision";
|
|
5
|
+
import { resolveCatalogAxes, resolveModelPolicy } from "../compat/resolve";
|
|
4
6
|
import { classifyModel } from "../compat/taxonomy";
|
|
7
|
+
import { Effort, THINKING_EFFORTS } from "../effort";
|
|
5
8
|
import { getBundledModels } from "../models";
|
|
6
9
|
import { toModelSpec } from "../provider-models/bundled-references";
|
|
7
|
-
import type { Model, ModelSpec } from "../types";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
import type { CursorModelRoute, Model, ModelSpec, TokenCost } from "../types";
|
|
11
|
+
import {
|
|
12
|
+
CURSOR_AVAILABLE_MODELS_PATH,
|
|
13
|
+
CURSOR_DEFAULT_BASE_URL,
|
|
14
|
+
CURSOR_GET_DEFAULT_MODEL_PATH,
|
|
15
|
+
CURSOR_GET_USABLE_MODELS_PATH,
|
|
16
|
+
cursorClientHeaders,
|
|
17
|
+
} from "../wire/cursor";
|
|
18
|
+
import {
|
|
19
|
+
type AvailableModelsResponse_ModelDetails,
|
|
20
|
+
AvailableModelsRequestSchema,
|
|
21
|
+
AvailableModelsResponseSchema,
|
|
22
|
+
GetDefaultModelForCliRequestSchema,
|
|
23
|
+
GetDefaultModelForCliResponseSchema,
|
|
24
|
+
GetUsableModelsRequestSchema,
|
|
25
|
+
GetUsableModelsResponseSchema,
|
|
26
|
+
} from "./cursor-proto";
|
|
27
|
+
import { create, fromBinary, toBinary, type MessageCodec, type ProtoMessage } from "./protobuf";
|
|
14
28
|
|
|
15
29
|
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
16
30
|
const DEFAULT_MAX_TOKENS = 64_000;
|
|
@@ -57,9 +71,7 @@ const CursorDecodedResponseSchema = type({
|
|
|
57
71
|
|
|
58
72
|
type CursorModelDetailsValue = typeof CursorModelDetailsSchema.infer;
|
|
59
73
|
|
|
60
|
-
/**
|
|
61
|
-
* Options for fetching dynamic Cursor models from `GetUsableModels`.
|
|
62
|
-
*/
|
|
74
|
+
/** Options for authenticated Cursor model discovery. */
|
|
63
75
|
export interface CursorModelDiscoveryOptions {
|
|
64
76
|
/** Cursor access token used for bearer authentication. */
|
|
65
77
|
apiKey: string;
|
|
@@ -74,103 +86,178 @@ export interface CursorModelDiscoveryOptions {
|
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
/**
|
|
77
|
-
*
|
|
89
|
+
* Joins Cursor's account-scoped model RPCs:
|
|
90
|
+
* - `AvailableModels` supplies rich base models, parameter axes, variants,
|
|
91
|
+
* capabilities, aliases, and cost-multiplier metadata.
|
|
92
|
+
* - `GetUsableModels` supplies the exact legacy wire slugs the account can run.
|
|
93
|
+
* - `GetDefaultModelForCli` identifies the account's current default.
|
|
78
94
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
95
|
+
* Per-token rates come from the reviewed `providers/cursor.kdl` rate card and
|
|
96
|
+
* the bundled rows; discovery only scales them by a variant's declared multiplier.
|
|
97
|
+
*
|
|
98
|
+
* Returns `null` unless at least one model-list RPC (`GetUsableModels` or
|
|
99
|
+
* `AvailableModels`) decoded. A successful empty catalog returns `[]`,
|
|
100
|
+
* preserving model-manager retry and fallback semantics.
|
|
81
101
|
*/
|
|
82
102
|
export async function fetchCursorUsableModels(
|
|
83
103
|
options: CursorModelDiscoveryOptions,
|
|
84
104
|
): Promise<ModelSpec<"cursor-agent">[] | null> {
|
|
85
105
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
const baseUrl = (options.baseUrl ?? CURSOR_DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
107
|
+
const usableRequest = create(GetUsableModelsRequestSchema, {
|
|
108
|
+
customModelIds: normalizeCustomModelIds(options.customModelIds),
|
|
109
|
+
});
|
|
110
|
+
const availableRequest = create(AvailableModelsRequestSchema, {
|
|
111
|
+
includeLongContextModels: true,
|
|
112
|
+
useModelParameters: true,
|
|
113
|
+
doNotUseMarkdown: true,
|
|
114
|
+
useCloudAgentEffortModes: true,
|
|
115
|
+
});
|
|
116
|
+
const defaultRequest = create(GetDefaultModelForCliRequestSchema, {});
|
|
92
117
|
|
|
93
|
-
|
|
118
|
+
const [usablePayload, availablePayload, defaultPayload] = await Promise.all([
|
|
119
|
+
fetchCursorUnary(
|
|
120
|
+
baseUrl,
|
|
121
|
+
CURSOR_GET_USABLE_MODELS_PATH,
|
|
122
|
+
toBinary(GetUsableModelsRequestSchema, usableRequest),
|
|
123
|
+
options,
|
|
124
|
+
timeoutMs,
|
|
125
|
+
),
|
|
126
|
+
fetchCursorUnary(
|
|
127
|
+
baseUrl,
|
|
128
|
+
CURSOR_AVAILABLE_MODELS_PATH,
|
|
129
|
+
toBinary(AvailableModelsRequestSchema, availableRequest),
|
|
130
|
+
options,
|
|
131
|
+
Math.min(timeoutMs, 2_000),
|
|
132
|
+
),
|
|
133
|
+
fetchCursorUnary(
|
|
134
|
+
baseUrl,
|
|
135
|
+
CURSOR_GET_DEFAULT_MODEL_PATH,
|
|
136
|
+
toBinary(GetDefaultModelForCliRequestSchema, defaultRequest),
|
|
137
|
+
options,
|
|
138
|
+
timeoutMs,
|
|
139
|
+
),
|
|
140
|
+
]);
|
|
141
|
+
const usable = decodeUnary(GetUsableModelsResponseSchema, usablePayload);
|
|
142
|
+
const available = decodeUnary(AvailableModelsResponseSchema, availablePayload);
|
|
143
|
+
// Only a decoded model list is authoritative: the default-model RPC alone
|
|
144
|
+
// must not publish an empty roster that replaces the cached catalog.
|
|
145
|
+
if (usable === null && available === null) return null;
|
|
146
|
+
const defaultModel = decodeUnary(GetDefaultModelForCliResponseSchema, defaultPayload)?.model;
|
|
147
|
+
const parsedUsable = CursorDecodedResponseSchema(usable);
|
|
148
|
+
const references = createCursorReferenceMap();
|
|
149
|
+
const legacyModels =
|
|
150
|
+
parsedUsable instanceof type.errors
|
|
151
|
+
? []
|
|
152
|
+
: normalizeCursorModels(parsedUsable.models, options.baseUrl, references);
|
|
153
|
+
const usableModelIds = usable === null ? undefined : new Set(legacyModels.map(model => model.id));
|
|
154
|
+
if (!available || available.models.length === 0) return legacyModels;
|
|
155
|
+
const richModels = normalizeRichCursorModels(
|
|
156
|
+
available.models,
|
|
157
|
+
usableModelIds,
|
|
158
|
+
options.baseUrl,
|
|
159
|
+
references,
|
|
160
|
+
defaultModel?.modelId,
|
|
161
|
+
defaultModel?.maxMode,
|
|
162
|
+
);
|
|
163
|
+
const richCatalogIds = collectRichCursorCatalogIds(available.models);
|
|
94
164
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
165
|
+
// Rich variants carry `legacy_slug`; retain an otherwise-unrepresented
|
|
166
|
+
// usable row so schema drift cannot hide a model the run endpoint accepts.
|
|
167
|
+
const representedIds = new Set<string>();
|
|
168
|
+
for (const model of richModels) {
|
|
169
|
+
representedIds.add(model.id);
|
|
170
|
+
representedIds.add(model.requestModelId ?? model.id);
|
|
171
|
+
for (const routeId of Object.keys(model.cursorModelRoutes ?? {})) representedIds.add(routeId);
|
|
172
|
+
}
|
|
173
|
+
for (const model of legacyModels) {
|
|
174
|
+
if (!representedIds.has(model.id) && !richCatalogIds.has(model.id)) richModels.push(model);
|
|
175
|
+
}
|
|
176
|
+
return richModels.sort((a, b) => a.id.localeCompare(b.id));
|
|
177
|
+
}
|
|
103
178
|
|
|
104
|
-
|
|
105
|
-
|
|
179
|
+
/**
|
|
180
|
+
* One unary RPC under a single deadline. HTTP/1.1 fetch goes first; the
|
|
181
|
+
* HTTP/2 Connect retry runs only after a transport-level failure (h2-only
|
|
182
|
+
* endpoints) and only with the remaining budget. An HTTP response of any
|
|
183
|
+
* status, or a deadline abort, is final.
|
|
184
|
+
*/
|
|
185
|
+
async function fetchCursorUnary(
|
|
186
|
+
baseUrl: string,
|
|
187
|
+
path: string,
|
|
188
|
+
body: Uint8Array<ArrayBuffer>,
|
|
189
|
+
options: CursorModelDiscoveryOptions,
|
|
190
|
+
timeoutMs: number,
|
|
191
|
+
): Promise<Uint8Array | null> {
|
|
192
|
+
const deadline = Date.now() + timeoutMs;
|
|
193
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
194
|
+
let response: Response;
|
|
195
|
+
try {
|
|
196
|
+
response = await fetch(new URL(path, baseUrl), {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
...cursorClientHeaders(options.apiKey, { clientVersion: options.clientVersion }),
|
|
200
|
+
"connect-protocol-version": "1",
|
|
201
|
+
"x-request-id": crypto.randomUUID(),
|
|
202
|
+
},
|
|
203
|
+
body,
|
|
204
|
+
signal,
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
const remainingMs = deadline - Date.now();
|
|
208
|
+
if (signal.aborted || remainingMs <= 0) return null;
|
|
209
|
+
// Older/custom Cursor endpoints may accept only an HTTP/2 Connect request.
|
|
210
|
+
return fetchViaHttp2(baseUrl, path, body, options, remainingMs);
|
|
211
|
+
}
|
|
212
|
+
if (!response.ok) return null;
|
|
213
|
+
try {
|
|
214
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
106
215
|
} catch {
|
|
107
216
|
return null;
|
|
108
217
|
}
|
|
109
218
|
}
|
|
110
219
|
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
"content-type": "application/proto",
|
|
114
|
-
te: "trailers",
|
|
115
|
-
authorization: `Bearer ${options.apiKey}`,
|
|
116
|
-
"x-ghost-mode": "true",
|
|
117
|
-
"x-cursor-client-version": options.clientVersion ?? CURSOR_DEFAULT_CLIENT_VERSION,
|
|
118
|
-
"x-cursor-client-type": "cli",
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** HTTP/2 transport required by Cursor API (HTTP/1.1 is rejected with 464). */
|
|
220
|
+
/** HTTP/2 transport used by Cursor's unary protobuf RPCs. */
|
|
123
221
|
async function fetchViaHttp2(
|
|
124
222
|
baseUrl: string,
|
|
223
|
+
path: string,
|
|
125
224
|
body: Uint8Array,
|
|
126
225
|
options: CursorModelDiscoveryOptions,
|
|
127
226
|
timeoutMs: number,
|
|
128
227
|
): Promise<Uint8Array | null> {
|
|
129
228
|
const { promise, resolve } = Promise.withResolvers<Uint8Array | null>();
|
|
130
229
|
const client = http2.connect(baseUrl);
|
|
230
|
+
let settled = false;
|
|
231
|
+
const finish = (value: Uint8Array | null): void => {
|
|
232
|
+
if (settled) return;
|
|
233
|
+
settled = true;
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
client.close();
|
|
236
|
+
resolve(value);
|
|
237
|
+
};
|
|
131
238
|
const timer = setTimeout(() => {
|
|
132
239
|
client.destroy();
|
|
133
|
-
|
|
240
|
+
finish(null);
|
|
134
241
|
}, timeoutMs);
|
|
135
242
|
|
|
136
|
-
client.on("error", () =>
|
|
137
|
-
clearTimeout(timer);
|
|
138
|
-
resolve(null);
|
|
139
|
-
});
|
|
140
|
-
|
|
243
|
+
client.on("error", () => finish(null));
|
|
141
244
|
const req = client.request({
|
|
142
245
|
":method": "POST",
|
|
143
|
-
":path":
|
|
144
|
-
|
|
246
|
+
":path": path,
|
|
247
|
+
te: "trailers",
|
|
248
|
+
...cursorClientHeaders(options.apiKey, { clientVersion: options.clientVersion }),
|
|
249
|
+
"connect-protocol-version": "1",
|
|
250
|
+
"x-request-id": crypto.randomUUID(),
|
|
145
251
|
});
|
|
146
|
-
|
|
147
252
|
const chunks: Buffer[] = [];
|
|
148
253
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
149
|
-
req.on("end", () =>
|
|
150
|
-
|
|
151
|
-
client.close();
|
|
152
|
-
resolve(new Uint8Array(Buffer.concat(chunks)));
|
|
153
|
-
});
|
|
154
|
-
req.on("error", () => {
|
|
155
|
-
clearTimeout(timer);
|
|
156
|
-
client.close();
|
|
157
|
-
resolve(null);
|
|
158
|
-
});
|
|
254
|
+
req.on("end", () => finish(new Uint8Array(Buffer.concat(chunks))));
|
|
255
|
+
req.on("error", () => finish(null));
|
|
159
256
|
req.on("response", headers => {
|
|
160
257
|
const status = Number(headers[":status"] ?? 0);
|
|
161
|
-
if (status < 200 || status >= 300)
|
|
162
|
-
clearTimeout(timer);
|
|
163
|
-
client.close();
|
|
164
|
-
resolve(null);
|
|
165
|
-
}
|
|
258
|
+
if (status < 200 || status >= 300) finish(null);
|
|
166
259
|
});
|
|
167
|
-
|
|
168
|
-
if (body.length > 0) {
|
|
169
|
-
req.end(Buffer.from(body));
|
|
170
|
-
} else {
|
|
171
|
-
req.end();
|
|
172
|
-
}
|
|
173
|
-
|
|
260
|
+
req.end(Buffer.from(body));
|
|
174
261
|
return promise;
|
|
175
262
|
}
|
|
176
263
|
|
|
@@ -200,22 +287,15 @@ function createCursorReferenceMap(): Map<string, ModelSpec<"cursor-agent">> {
|
|
|
200
287
|
return references;
|
|
201
288
|
}
|
|
202
289
|
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
return fromBinary(GetUsableModelsResponseSchema, framedBody);
|
|
212
|
-
} catch {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
290
|
+
function decodeUnary<TMessage extends ProtoMessage>(
|
|
291
|
+
schema: MessageCodec<TMessage>,
|
|
292
|
+
payload: Uint8Array | null,
|
|
293
|
+
): TMessage | null {
|
|
294
|
+
// An empty 200 body is a valid, fully-defaulted protobuf message.
|
|
295
|
+
if (payload === null) return null;
|
|
296
|
+
const body = decodeConnectUnaryBody(payload) ?? payload;
|
|
217
297
|
try {
|
|
218
|
-
return fromBinary(
|
|
298
|
+
return fromBinary(schema, body);
|
|
219
299
|
} catch {
|
|
220
300
|
return null;
|
|
221
301
|
}
|
|
@@ -270,6 +350,655 @@ function isCursorGlm52CodingModel(id: string): boolean {
|
|
|
270
350
|
const floor = parseRevision("5.2");
|
|
271
351
|
return revision !== undefined && floor !== undefined && compareRevision(revision, floor) >= 0;
|
|
272
352
|
}
|
|
353
|
+
type RichCursorVariant = AvailableModelsResponse_ModelDetails["variants"][number];
|
|
354
|
+
|
|
355
|
+
type CursorRouteEffort = Effort | "off";
|
|
356
|
+
|
|
357
|
+
interface NormalizedRichVariant {
|
|
358
|
+
id: string;
|
|
359
|
+
parameters: { id: string; value: string }[];
|
|
360
|
+
variant: RichCursorVariant | undefined;
|
|
361
|
+
effort: CursorRouteEffort | undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function collectRichCursorCatalogIds(models: readonly AvailableModelsResponse_ModelDetails[]): Set<string> {
|
|
365
|
+
const ids = new Set<string>();
|
|
366
|
+
for (const details of models) {
|
|
367
|
+
const baseId = details.name.trim();
|
|
368
|
+
if (baseId) ids.add(baseId);
|
|
369
|
+
for (const id of [...details.legacySlugs, ...details.idAliases]) {
|
|
370
|
+
if (id.trim()) ids.add(id.trim());
|
|
371
|
+
}
|
|
372
|
+
for (const [index, variant] of details.variants.entries()) {
|
|
373
|
+
const parameters = variant.parameterValues
|
|
374
|
+
.map(parameter => ({ id: parameter.id.trim(), value: parameter.value.trim() }))
|
|
375
|
+
.filter(parameter => parameter.id.length > 0);
|
|
376
|
+
const parameterSuffix = parameters.map(parameter => `${parameter.id}=${parameter.value}`).join(",");
|
|
377
|
+
const id =
|
|
378
|
+
variant.legacySlug?.trim() ||
|
|
379
|
+
variant.variantStringRepresentation?.trim() ||
|
|
380
|
+
(parameterSuffix ? `${baseId}@${parameterSuffix}` : `${baseId}@variant-${index + 1}`);
|
|
381
|
+
if (id) ids.add(id);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return ids;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const CURSOR_REASONING_PARAMETER_IDS = new Set([
|
|
388
|
+
"effort",
|
|
389
|
+
"reasoning",
|
|
390
|
+
"reasoning_effort",
|
|
391
|
+
"thinking",
|
|
392
|
+
"thinking_effort",
|
|
393
|
+
]);
|
|
394
|
+
|
|
395
|
+
/** The declared definition value a variant parameter selects, when Cursor defines one. */
|
|
396
|
+
function cursorParameterValue(
|
|
397
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
398
|
+
parameter: { id: string; value: string },
|
|
399
|
+
): { increasesModelCost?: boolean; markdownTooltip?: string; definitionTooltip?: string } | undefined {
|
|
400
|
+
const definition = details.parameterDefinitions.find(candidate => candidate.id === parameter.id);
|
|
401
|
+
if (!definition) return undefined;
|
|
402
|
+
const booleanValue = definition.parameterType?.booleanParameter?.values.find(
|
|
403
|
+
candidate => candidate.value === parameter.value,
|
|
404
|
+
);
|
|
405
|
+
const enumValue = definition.parameterType?.enumParameter?.values.find(
|
|
406
|
+
candidate => candidate.value === parameter.value,
|
|
407
|
+
);
|
|
408
|
+
const selected = booleanValue ?? enumValue;
|
|
409
|
+
if (!selected) return undefined;
|
|
410
|
+
return {
|
|
411
|
+
increasesModelCost: selected.increasesModelCost,
|
|
412
|
+
markdownTooltip: enumValue?.markdownTooltip,
|
|
413
|
+
definitionTooltip: definition.markdownTooltip,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function cursorVariantCostMultiplier(
|
|
418
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
419
|
+
entry: NormalizedRichVariant,
|
|
420
|
+
): number {
|
|
421
|
+
let multiplier = 1;
|
|
422
|
+
for (const parameter of entry.parameters) {
|
|
423
|
+
const selected = cursorParameterValue(details, parameter);
|
|
424
|
+
if (selected?.increasesModelCost !== true) continue;
|
|
425
|
+
for (const description of [selected.markdownTooltip, selected.definitionTooltip]) {
|
|
426
|
+
const match = description ? /\b(\d+(?:\.\d+)?)\s*[x×](?![a-z])/i.exec(description) : undefined;
|
|
427
|
+
if (!match?.[1]) continue;
|
|
428
|
+
const parameterMultiplier = Number(match[1]);
|
|
429
|
+
if (Number.isFinite(parameterMultiplier) && parameterMultiplier > 0) {
|
|
430
|
+
multiplier *= parameterMultiplier;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return multiplier;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function scaleTokenCost(cost: TokenCost, multiplier: number): TokenCost {
|
|
439
|
+
if (multiplier === 1) return cost;
|
|
440
|
+
return {
|
|
441
|
+
input: cost.input * multiplier,
|
|
442
|
+
output: cost.output * multiplier,
|
|
443
|
+
cacheRead: cost.cacheRead * multiplier,
|
|
444
|
+
cacheWrite: cost.cacheWrite * multiplier,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Price a rich variant from the reviewed `providers/cursor.kdl` rate card,
|
|
450
|
+
* which keys on Cursor wire slugs (`claude-opus-4-8-high`, `gpt-5.4`). Cursor
|
|
451
|
+
* bills per price class — the values of the model's cost-bearing parameters
|
|
452
|
+
* (`fast`, `context`) — not per effort or thinking toggle, so every slug in
|
|
453
|
+
* the variant's own class shares its price. The lane id leads the own-class
|
|
454
|
+
* slugs: `buildModel` later resolves the same card by lane id, so a lane-keyed
|
|
455
|
+
* `cost-patch` must win here too or the two would disagree. Multiplier rule:
|
|
456
|
+
* - `cost-patch` on an own-class slug is that class's reviewed price and is
|
|
457
|
+
* final, even for fast classes (`claude-opus-4-7-high-fast` $30/$150);
|
|
458
|
+
* - `cost-fallback` rows, and any base-class slug or the model name, carry the
|
|
459
|
+
* base card, scaled by the declared multiplier (`cursorVariantCostMultiplier`).
|
|
460
|
+
*/
|
|
461
|
+
function resolveRichCursorCardCost(
|
|
462
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
463
|
+
entry: NormalizedRichVariant,
|
|
464
|
+
laneId: string,
|
|
465
|
+
): TokenCost | undefined {
|
|
466
|
+
const costAxes = details.parameterDefinitions
|
|
467
|
+
.filter(definition =>
|
|
468
|
+
[
|
|
469
|
+
...(definition.parameterType?.booleanParameter?.values ?? []),
|
|
470
|
+
...(definition.parameterType?.enumParameter?.values ?? []),
|
|
471
|
+
].some(value => value.increasesModelCost === true),
|
|
472
|
+
)
|
|
473
|
+
.map(definition => definition.id);
|
|
474
|
+
const priceClass = (parameters: readonly { id: string; value: string }[]): string =>
|
|
475
|
+
costAxes.map(id => parameters.find(parameter => parameter.id === id)?.value ?? "").join("\u0000");
|
|
476
|
+
const entryClass = priceClass(entry.parameters);
|
|
477
|
+
const ownSlugs = [laneId, entry.id];
|
|
478
|
+
const baseSlugs = [details.name.trim()];
|
|
479
|
+
for (const variant of details.variants) {
|
|
480
|
+
const slug = variant.legacySlug?.trim();
|
|
481
|
+
if (!slug) continue;
|
|
482
|
+
const parameters = variant.parameterValues.map(parameter => ({
|
|
483
|
+
id: parameter.id.trim(),
|
|
484
|
+
value: parameter.value.trim(),
|
|
485
|
+
}));
|
|
486
|
+
if (priceClass(parameters) === entryClass) {
|
|
487
|
+
ownSlugs.push(slug);
|
|
488
|
+
} else if (!parameters.some(parameter => cursorParameterValue(details, parameter)?.increasesModelCost === true)) {
|
|
489
|
+
baseSlugs.push(slug);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let base: TokenCost | undefined;
|
|
493
|
+
for (const slug of ownSlugs) {
|
|
494
|
+
const card = resolveCursorCardAxes(slug);
|
|
495
|
+
if (card.patch) return card.patch;
|
|
496
|
+
base ??= card.fallback;
|
|
497
|
+
}
|
|
498
|
+
for (const slug of baseSlugs) {
|
|
499
|
+
if (base) break;
|
|
500
|
+
const card = resolveCursorCardAxes(slug);
|
|
501
|
+
base = card.patch ?? card.fallback;
|
|
502
|
+
}
|
|
503
|
+
return base && scaleTokenCost(base, cursorVariantCostMultiplier(details, entry));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function resolveCursorCardAxes(slug: string): { patch?: TokenCost; fallback?: TokenCost } {
|
|
507
|
+
const axes = resolveCatalogAxes({
|
|
508
|
+
id: slug,
|
|
509
|
+
name: slug,
|
|
510
|
+
api: "cursor-agent",
|
|
511
|
+
provider: "cursor",
|
|
512
|
+
baseUrl: CURSOR_DEFAULT_BASE_URL,
|
|
513
|
+
reasoning: false,
|
|
514
|
+
input: ["text"],
|
|
515
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
516
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
517
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
518
|
+
});
|
|
519
|
+
const card = (payload: unknown): TokenCost | undefined => {
|
|
520
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
521
|
+
const field = (key: string): number | undefined => {
|
|
522
|
+
const value = Reflect.get(payload, key);
|
|
523
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
524
|
+
};
|
|
525
|
+
const input = field("input");
|
|
526
|
+
const output = field("output");
|
|
527
|
+
if (input === undefined || output === undefined) return undefined;
|
|
528
|
+
return { input, output, cacheRead: field("cacheRead") ?? 0, cacheWrite: field("cacheWrite") ?? 0 };
|
|
529
|
+
};
|
|
530
|
+
return { patch: card(axes.costPatch), fallback: card(axes.costFallback) };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeRichCursorModels(
|
|
534
|
+
models: readonly AvailableModelsResponse_ModelDetails[],
|
|
535
|
+
usableModelIds: ReadonlySet<string> | undefined,
|
|
536
|
+
baseUrlOverride: string | undefined,
|
|
537
|
+
references: Map<string, ModelSpec<"cursor-agent">>,
|
|
538
|
+
defaultModelId: string | undefined,
|
|
539
|
+
defaultMaxMode: boolean | undefined,
|
|
540
|
+
): ModelSpec<"cursor-agent">[] {
|
|
541
|
+
const normalized: ModelSpec<"cursor-agent">[] = [];
|
|
542
|
+
for (const details of models) {
|
|
543
|
+
const baseId = details.name.trim();
|
|
544
|
+
if (!baseId || details.supportsAgent === false || details.isChatOnly === true) continue;
|
|
545
|
+
// OMP always sends Cursor's zero-data-retention header. Advertising a
|
|
546
|
+
// retention-required model would expose a route every invocation rejects.
|
|
547
|
+
if (details.requiresDataRetention === true) continue;
|
|
548
|
+
|
|
549
|
+
const variants = normalizeRichCursorVariants(details, baseId, usableModelIds);
|
|
550
|
+
if (variants.length === 0) continue;
|
|
551
|
+
// The bare lane id belongs to Cursor's flagged default configuration, so
|
|
552
|
+
// seed axis defaults from it before falling back to first-seen values;
|
|
553
|
+
// otherwise lane names would follow the server's variant order.
|
|
554
|
+
const defaultRank = (entry: NormalizedRichVariant): number =>
|
|
555
|
+
entry.variant?.isDefaultNonMaxConfig === true ? 0 : entry.variant?.isDefaultMaxConfig === true ? 1 : 2;
|
|
556
|
+
const parameterDefaults = new Map<string, string>();
|
|
557
|
+
for (const entry of variants.toSorted((left, right) => defaultRank(left) - defaultRank(right))) {
|
|
558
|
+
for (const parameter of cursorLaneParameters(entry.parameters)) {
|
|
559
|
+
if (!parameterDefaults.has(parameter.id)) parameterDefaults.set(parameter.id, parameter.value);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const lanes = new Map<string, NormalizedRichVariant[]>();
|
|
564
|
+
for (const entry of variants) {
|
|
565
|
+
const dimensions = cursorLaneParameters(entry.parameters);
|
|
566
|
+
const key = JSON.stringify([dimensions, entry.variant?.isMaxMode === true]);
|
|
567
|
+
const lane = lanes.get(key);
|
|
568
|
+
if (lane) {
|
|
569
|
+
lane.push(entry);
|
|
570
|
+
} else {
|
|
571
|
+
lanes.set(key, [entry]);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const claimedLaneIds = new Set<string>();
|
|
576
|
+
for (const entries of lanes.values()) {
|
|
577
|
+
const first = entries[0];
|
|
578
|
+
if (!first) continue;
|
|
579
|
+
const dimensions = cursorLaneParameters(first.parameters);
|
|
580
|
+
const laneId = cursorLaneId(
|
|
581
|
+
baseId,
|
|
582
|
+
dimensions,
|
|
583
|
+
parameterDefaults,
|
|
584
|
+
first.variant?.isMaxMode === true,
|
|
585
|
+
claimedLaneIds,
|
|
586
|
+
);
|
|
587
|
+
const laneName = cursorLaneName(details, laneId, baseId);
|
|
588
|
+
normalized.push(
|
|
589
|
+
...buildRichCursorLane(
|
|
590
|
+
details,
|
|
591
|
+
entries,
|
|
592
|
+
laneId,
|
|
593
|
+
laneName,
|
|
594
|
+
baseUrlOverride,
|
|
595
|
+
references,
|
|
596
|
+
defaultModelId,
|
|
597
|
+
defaultMaxMode,
|
|
598
|
+
),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return normalized.sort((a, b) => a.id.localeCompare(b.id));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function normalizeRichCursorVariants(
|
|
606
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
607
|
+
baseId: string,
|
|
608
|
+
usableModelIds: ReadonlySet<string> | undefined,
|
|
609
|
+
): NormalizedRichVariant[] {
|
|
610
|
+
if (details.variants.length === 0) {
|
|
611
|
+
const candidates = [baseId, ...details.legacySlugs, ...details.idAliases];
|
|
612
|
+
if (usableModelIds !== undefined && !candidates.some(id => usableModelIds.has(id))) return [];
|
|
613
|
+
return [{ id: baseId, parameters: [], variant: undefined, effort: undefined }];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const variants: NormalizedRichVariant[] = [];
|
|
617
|
+
const seenRoutes = new Set<string>();
|
|
618
|
+
for (const [index, variant] of details.variants.entries()) {
|
|
619
|
+
if (isRichVariantBlocked(details, variant)) continue;
|
|
620
|
+
const parameters = variant.parameterValues
|
|
621
|
+
.map(parameter => ({ id: parameter.id.trim(), value: parameter.value.trim() }))
|
|
622
|
+
.filter(parameter => parameter.id.length > 0);
|
|
623
|
+
const parameterSuffix = parameters.map(parameter => `${parameter.id}=${parameter.value}`).join(",");
|
|
624
|
+
const id =
|
|
625
|
+
variant.legacySlug?.trim() ||
|
|
626
|
+
variant.variantStringRepresentation?.trim() ||
|
|
627
|
+
(parameterSuffix ? `${baseId}@${parameterSuffix}` : `${baseId}@variant-${index + 1}`);
|
|
628
|
+
const entitlementIds = [variant.legacySlug?.trim(), id, baseId, ...details.idAliases].filter(
|
|
629
|
+
(candidate): candidate is string => Boolean(candidate),
|
|
630
|
+
);
|
|
631
|
+
if (usableModelIds !== undefined && !entitlementIds.some(candidate => usableModelIds.has(candidate))) continue;
|
|
632
|
+
const routeSignature = `${parameterSuffix}\u0000${variant.isMaxMode === true ? "max" : "standard"}`;
|
|
633
|
+
if (seenRoutes.has(routeSignature)) continue;
|
|
634
|
+
seenRoutes.add(routeSignature);
|
|
635
|
+
variants.push({
|
|
636
|
+
id,
|
|
637
|
+
parameters,
|
|
638
|
+
variant,
|
|
639
|
+
effort: cursorVariantEffort(parameters),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return variants;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function cursorVariantEffort(parameters: readonly { id: string; value: string }[]): CursorRouteEffort | undefined {
|
|
646
|
+
const thinking = parameters.find(parameter => parameter.id === "thinking")?.value.toLowerCase();
|
|
647
|
+
if (thinking === "false" || thinking === "off" || thinking === "none") return "off";
|
|
648
|
+
const rawEffort = parameters.find(parameter =>
|
|
649
|
+
["reasoning", "reasoning_effort", "thinking_effort", "effort"].includes(parameter.id),
|
|
650
|
+
)?.value;
|
|
651
|
+
if (rawEffort === undefined) return undefined;
|
|
652
|
+
const normalized = rawEffort.toLowerCase().replace(/[_\s]+/g, "-");
|
|
653
|
+
if (normalized === "none" || normalized === "off" || normalized === "disabled") return "off";
|
|
654
|
+
if (
|
|
655
|
+
normalized === Effort.Minimal ||
|
|
656
|
+
normalized === Effort.Low ||
|
|
657
|
+
normalized === Effort.Medium ||
|
|
658
|
+
normalized === Effort.High ||
|
|
659
|
+
normalized === Effort.XHigh ||
|
|
660
|
+
normalized === Effort.Max
|
|
661
|
+
) {
|
|
662
|
+
return normalized;
|
|
663
|
+
}
|
|
664
|
+
if (normalized === "extra-high") return Effort.XHigh;
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function cursorLaneParameters(parameters: readonly { id: string; value: string }[]): { id: string; value: string }[] {
|
|
669
|
+
return parameters.filter(parameter => !CURSOR_REASONING_PARAMETER_IDS.has(parameter.id));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function cursorLaneId(
|
|
673
|
+
baseId: string,
|
|
674
|
+
parameters: readonly { id: string; value: string }[],
|
|
675
|
+
defaults: ReadonlyMap<string, string>,
|
|
676
|
+
isMaxMode: boolean,
|
|
677
|
+
claimed: Set<string>,
|
|
678
|
+
): string {
|
|
679
|
+
const suffixes: string[] = [];
|
|
680
|
+
for (const parameter of parameters) {
|
|
681
|
+
const id = sanitizeCursorLanePart(parameter.id);
|
|
682
|
+
const value = sanitizeCursorLanePart(parameter.value);
|
|
683
|
+
// Boolean lanes are named by their `true` side regardless of variant
|
|
684
|
+
// order: Cursor lists `composer-2.5`'s Fast variant first, and treating
|
|
685
|
+
// that as the default would hand the bare id to the Fast route.
|
|
686
|
+
if (!id || !value || parameter.value === "false") continue;
|
|
687
|
+
if (parameter.value === "true") {
|
|
688
|
+
suffixes.push(id);
|
|
689
|
+
} else if (parameter.value === defaults.get(parameter.id)) {
|
|
690
|
+
continue;
|
|
691
|
+
} else if (parameter.id === "context") {
|
|
692
|
+
suffixes.push(value);
|
|
693
|
+
} else {
|
|
694
|
+
suffixes.push(`${id}-${value}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const root = [baseId, ...suffixes].join("-");
|
|
698
|
+
let candidate = root;
|
|
699
|
+
if (claimed.has(candidate)) candidate = `${root}-${isMaxMode ? "max-mode" : "standard"}`;
|
|
700
|
+
let duplicate = 2;
|
|
701
|
+
while (claimed.has(candidate)) {
|
|
702
|
+
candidate = `${root}-${duplicate}`;
|
|
703
|
+
duplicate += 1;
|
|
704
|
+
}
|
|
705
|
+
claimed.add(candidate);
|
|
706
|
+
return candidate;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function sanitizeCursorLanePart(value: string): string {
|
|
710
|
+
return value
|
|
711
|
+
.toLowerCase()
|
|
712
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
713
|
+
.replace(/^-+|-+$/g, "");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function cursorLaneName(details: AvailableModelsResponse_ModelDetails, laneId: string, baseId: string): string {
|
|
717
|
+
const baseName =
|
|
718
|
+
details.clientDisplayName?.trim() || details.serverModelName?.trim() || details.name.trim() || baseId;
|
|
719
|
+
const suffix = laneId
|
|
720
|
+
.slice(baseId.length)
|
|
721
|
+
.replace(/^-/, "")
|
|
722
|
+
.split("-")
|
|
723
|
+
.filter(Boolean)
|
|
724
|
+
.map(part => (part === "1m" ? "1M" : part.charAt(0).toUpperCase() + part.slice(1)))
|
|
725
|
+
.join(" ");
|
|
726
|
+
return suffix ? `${baseName} ${suffix}` : baseName;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function buildRichCursorLane(
|
|
730
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
731
|
+
entries: readonly NormalizedRichVariant[],
|
|
732
|
+
laneId: string,
|
|
733
|
+
laneName: string,
|
|
734
|
+
baseUrlOverride: string | undefined,
|
|
735
|
+
references: Map<string, ModelSpec<"cursor-agent">>,
|
|
736
|
+
defaultModelId: string | undefined,
|
|
737
|
+
defaultMaxMode: boolean | undefined,
|
|
738
|
+
): ModelSpec<"cursor-agent">[] {
|
|
739
|
+
// `thinking=true` without an effort axis gets its own slot so the
|
|
740
|
+
// toggle's thinking route survives selection beside the `off` route.
|
|
741
|
+
const isThinkingToggle = (entry: NormalizedRichVariant): boolean =>
|
|
742
|
+
entry.effort === undefined &&
|
|
743
|
+
entry.parameters.some(parameter => parameter.id === "thinking" && parameter.value.toLowerCase() === "true");
|
|
744
|
+
const selectedByEffort = new Map<CursorRouteEffort | "thinking" | "fixed", NormalizedRichVariant>();
|
|
745
|
+
for (const entry of entries) {
|
|
746
|
+
const slot = entry.effort ?? (isThinkingToggle(entry) ? "thinking" : "fixed");
|
|
747
|
+
const current = selectedByEffort.get(slot);
|
|
748
|
+
if (
|
|
749
|
+
current === undefined ||
|
|
750
|
+
cursorVariantPreference(details, entry, defaultModelId, defaultMaxMode) >
|
|
751
|
+
cursorVariantPreference(details, current, defaultModelId, defaultMaxMode)
|
|
752
|
+
) {
|
|
753
|
+
selectedByEffort.set(slot, entry);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const selected = [...selectedByEffort.values()];
|
|
757
|
+
if (selected.length === 0) return [];
|
|
758
|
+
|
|
759
|
+
const routeKeys = new Map<NormalizedRichVariant, string>();
|
|
760
|
+
const usedRouteKeys = new Set<string>();
|
|
761
|
+
const routes: Record<string, CursorModelRoute> = {};
|
|
762
|
+
for (const entry of selected) {
|
|
763
|
+
let routeKey = entry.id;
|
|
764
|
+
if (usedRouteKeys.has(routeKey)) {
|
|
765
|
+
routeKey =
|
|
766
|
+
entry.variant?.variantStringRepresentation?.trim() ||
|
|
767
|
+
`${entry.id}@${entry.parameters.map(parameter => `${parameter.id}=${parameter.value}`).join(",")}`;
|
|
768
|
+
}
|
|
769
|
+
let duplicate = 2;
|
|
770
|
+
const root = routeKey;
|
|
771
|
+
while (usedRouteKeys.has(routeKey)) {
|
|
772
|
+
routeKey = `${root}#${duplicate}`;
|
|
773
|
+
duplicate += 1;
|
|
774
|
+
}
|
|
775
|
+
usedRouteKeys.add(routeKey);
|
|
776
|
+
routeKeys.set(entry, routeKey);
|
|
777
|
+
routes[routeKey] = {
|
|
778
|
+
modelId: details.name.trim(),
|
|
779
|
+
parameters: entry.parameters,
|
|
780
|
+
...(entry.variant?.isMaxMode === undefined ? undefined : { maxMode: entry.variant.isMaxMode }),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const members: ModelSpec<"cursor-agent">[] = [];
|
|
785
|
+
const routing: Partial<Record<CursorRouteEffort, string>> = {};
|
|
786
|
+
let defaultMember: string | undefined;
|
|
787
|
+
let thinkingToggleMember: ModelSpec<"cursor-agent"> | undefined;
|
|
788
|
+
for (const entry of selected) {
|
|
789
|
+
const routeKey = routeKeys.get(entry);
|
|
790
|
+
if (!routeKey) continue;
|
|
791
|
+
const variant = entry.variant;
|
|
792
|
+
const reference =
|
|
793
|
+
references.get(entry.id) ??
|
|
794
|
+
references.get(details.name.trim()) ??
|
|
795
|
+
details.legacySlugs.map(id => references.get(id)).find(candidate => candidate !== undefined);
|
|
796
|
+
const isMaxMode = variant?.isMaxMode ?? false;
|
|
797
|
+
const contextParameter = entry.parameters.find(parameter => parameter.id === "context")?.value;
|
|
798
|
+
const contextLimit =
|
|
799
|
+
parseCursorContextWindow(contextParameter) ??
|
|
800
|
+
(isMaxMode ? details.contextTokenLimitForMaxMode : details.contextTokenLimit);
|
|
801
|
+
const discoveredContextWindow =
|
|
802
|
+
contextLimit ?? details.autoContextExtendedMaxTokens ?? details.autoContextMaxTokens;
|
|
803
|
+
const fallbackContext = reference?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
804
|
+
const input: ("text" | "image")[] =
|
|
805
|
+
details.supportsImages === undefined
|
|
806
|
+
? resolveCursorInput(entry.id, reference?.input)
|
|
807
|
+
: details.supportsImages
|
|
808
|
+
? ["text", "image"]
|
|
809
|
+
: ["text"];
|
|
810
|
+
const isProviderDefault = isCursorProviderDefault(details, entry, defaultModelId, defaultMaxMode);
|
|
811
|
+
const reasoning =
|
|
812
|
+
entry.effort === "off"
|
|
813
|
+
? false
|
|
814
|
+
: entry.effort !== undefined || isThinkingToggle(entry)
|
|
815
|
+
? true
|
|
816
|
+
: (details.supportsThinking ?? reference?.reasoning ?? false);
|
|
817
|
+
// Price precedence: the reviewed KDL rate card by lane id and wire slug,
|
|
818
|
+
// then the member's own bundled reference (a sibling reference carries
|
|
819
|
+
// the base card, so it takes the declared multiplier); zero only when
|
|
820
|
+
// neither knows the price.
|
|
821
|
+
const referenceCost =
|
|
822
|
+
reference && (reference.cost.input !== 0 || reference.cost.output !== 0)
|
|
823
|
+
? reference === references.get(entry.id)
|
|
824
|
+
? reference.cost
|
|
825
|
+
: scaleTokenCost(reference.cost, cursorVariantCostMultiplier(details, entry))
|
|
826
|
+
: undefined;
|
|
827
|
+
const cost = resolveRichCursorCardCost(details, entry, laneId) ??
|
|
828
|
+
referenceCost ?? {
|
|
829
|
+
input: 0,
|
|
830
|
+
output: 0,
|
|
831
|
+
cacheRead: 0,
|
|
832
|
+
cacheWrite: 0,
|
|
833
|
+
};
|
|
834
|
+
members.push({
|
|
835
|
+
...(reference ?? {
|
|
836
|
+
id: routeKey,
|
|
837
|
+
name: laneName,
|
|
838
|
+
api: "cursor-agent" as const,
|
|
839
|
+
provider: "cursor" as const,
|
|
840
|
+
baseUrl: baseUrlOverride ?? CURSOR_DEFAULT_BASE_URL,
|
|
841
|
+
reasoning,
|
|
842
|
+
input,
|
|
843
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
844
|
+
contextWindow: fallbackContext,
|
|
845
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
846
|
+
}),
|
|
847
|
+
id: routeKey,
|
|
848
|
+
name: laneName,
|
|
849
|
+
baseUrl: baseUrlOverride ?? reference?.baseUrl ?? CURSOR_DEFAULT_BASE_URL,
|
|
850
|
+
reasoning,
|
|
851
|
+
input,
|
|
852
|
+
cost,
|
|
853
|
+
supportsTools: details.supportsAgent ?? reference?.supportsTools,
|
|
854
|
+
contextWindow: discoveredContextWindow ?? fallbackContext,
|
|
855
|
+
cursorMaxMode: isMaxMode,
|
|
856
|
+
cursorModelParameters: entry.parameters,
|
|
857
|
+
cursorModelRoutes: routes,
|
|
858
|
+
cursorPrice: details.price,
|
|
859
|
+
cursorRequiresDataRetention: details.requiresDataRetention,
|
|
860
|
+
cursorSupportsAgent: details.supportsAgent,
|
|
861
|
+
cursorSupportsSandboxing: details.supportsSandboxing,
|
|
862
|
+
isProviderDefault,
|
|
863
|
+
isRecommended: isProviderDefault || details.defaultOn,
|
|
864
|
+
description:
|
|
865
|
+
variant?.tagline ??
|
|
866
|
+
details.tagline ??
|
|
867
|
+
cursorTooltipBlurb(variant?.tooltipData?.markdownContent) ??
|
|
868
|
+
cursorTooltipBlurb(details.tooltipData?.markdownContent) ??
|
|
869
|
+
reference?.description,
|
|
870
|
+
});
|
|
871
|
+
if (entry.effort !== undefined) routing[entry.effort] = routeKey;
|
|
872
|
+
else if (isThinkingToggle(entry)) thinkingToggleMember = members.at(-1);
|
|
873
|
+
if (isProviderDefault) defaultMember = routeKey;
|
|
874
|
+
}
|
|
875
|
+
if (members.length === 0) return [];
|
|
876
|
+
// A `thinking=true` variant without an effort axis is the lane's thinking
|
|
877
|
+
// route for every effort the model supports, mirroring the bare/`-thinking`
|
|
878
|
+
// pair rule (`deriveThinkingPairFamilies`): off → `thinking=false`, each
|
|
879
|
+
// effort → `thinking=true`. Efforts come from the member's baked surface,
|
|
880
|
+
// else the resolved policy (KDL `thinking-efforts`, class defaults).
|
|
881
|
+
const toggleEfforts: Effort[] = [];
|
|
882
|
+
if (thinkingToggleMember) {
|
|
883
|
+
const { compat: _compat, ...policySpec } = thinkingToggleMember;
|
|
884
|
+
const surfaceEfforts = thinkingToggleMember.thinking?.efforts.length
|
|
885
|
+
? thinkingToggleMember.thinking.efforts
|
|
886
|
+
: (resolveModelPolicy({ ...policySpec, reasoning: true, thinking: undefined }).thinking?.efforts ?? []);
|
|
887
|
+
for (const effort of surfaceEfforts) {
|
|
888
|
+
if (routing[effort] !== undefined) continue;
|
|
889
|
+
routing[effort] = thinkingToggleMember.id;
|
|
890
|
+
toggleEfforts.push(effort);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
defaultMember ??= selected
|
|
895
|
+
.filter(entry => entry.variant?.isDefaultNonMaxConfig === true || entry.variant?.isDefaultMaxConfig === true)
|
|
896
|
+
.map(entry => routeKeys.get(entry))
|
|
897
|
+
.find((routeKey): routeKey is string => routeKey !== undefined);
|
|
898
|
+
defaultMember ??= routing.off ?? members[0]?.id;
|
|
899
|
+
const efforts = THINKING_EFFORTS.filter(effort => routing[effort] !== undefined);
|
|
900
|
+
const family: EffortVariantFamily = {
|
|
901
|
+
id: laneId,
|
|
902
|
+
name: laneName,
|
|
903
|
+
members: members.map(member => member.id),
|
|
904
|
+
routing,
|
|
905
|
+
...(defaultMember === undefined ? undefined : { defaultMember }),
|
|
906
|
+
...(efforts.length === 0
|
|
907
|
+
? undefined
|
|
908
|
+
: {
|
|
909
|
+
thinking: {
|
|
910
|
+
mode: "effort",
|
|
911
|
+
efforts,
|
|
912
|
+
...(routing.off === undefined ? { requiresEffort: true } : {}),
|
|
913
|
+
...(defaultMember === undefined || toggleEfforts.length > 0
|
|
914
|
+
? undefined
|
|
915
|
+
: {
|
|
916
|
+
defaultLevel: efforts.find(effort => routing[effort] === defaultMember),
|
|
917
|
+
}),
|
|
918
|
+
},
|
|
919
|
+
}),
|
|
920
|
+
};
|
|
921
|
+
return collapseVariants(members, { table: { families: [family] } });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* The one-line model blurb from Cursor's IDE tooltip. The tooltip is HTML for
|
|
926
|
+
* Cursor's editor (`<br />` separators, a bold title, context/effort lines,
|
|
927
|
+
* styled warning spans); the model's prose sentence is the second segment.
|
|
928
|
+
* Context and effort are already separate model fields, so the rest is dropped.
|
|
929
|
+
*/
|
|
930
|
+
function cursorTooltipBlurb(markdown: string | undefined): string | undefined {
|
|
931
|
+
if (!markdown) return undefined;
|
|
932
|
+
const segments = markdown
|
|
933
|
+
.split(/<br\s*\/?>/i)
|
|
934
|
+
.map(segment =>
|
|
935
|
+
segment
|
|
936
|
+
.replace(/<[^>]+>/g, "")
|
|
937
|
+
.replace(/[*_`]/g, "")
|
|
938
|
+
.trim(),
|
|
939
|
+
)
|
|
940
|
+
.filter(segment => segment.length > 0);
|
|
941
|
+
return segments[1] ?? segments[0];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function cursorVariantPreference(
|
|
945
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
946
|
+
entry: NormalizedRichVariant,
|
|
947
|
+
defaultModelId: string | undefined,
|
|
948
|
+
defaultMaxMode: boolean | undefined,
|
|
949
|
+
): number {
|
|
950
|
+
if (isCursorProviderDefault(details, entry, defaultModelId, defaultMaxMode)) return 3;
|
|
951
|
+
if (entry.variant?.isDefaultNonMaxConfig === true || entry.variant?.isDefaultMaxConfig === true) return 2;
|
|
952
|
+
return 1;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function isCursorProviderDefault(
|
|
956
|
+
details: AvailableModelsResponse_ModelDetails,
|
|
957
|
+
entry: NormalizedRichVariant,
|
|
958
|
+
defaultModelId: string | undefined,
|
|
959
|
+
defaultMaxMode: boolean | undefined,
|
|
960
|
+
): boolean {
|
|
961
|
+
const variant = entry.variant;
|
|
962
|
+
if (defaultModelId !== undefined) {
|
|
963
|
+
if (defaultModelId === entry.id) {
|
|
964
|
+
return defaultMaxMode === undefined || defaultMaxMode === (variant?.isMaxMode ?? false);
|
|
965
|
+
}
|
|
966
|
+
if (
|
|
967
|
+
defaultModelId !== details.name &&
|
|
968
|
+
!details.legacySlugs.includes(defaultModelId) &&
|
|
969
|
+
!details.idAliases.includes(defaultModelId)
|
|
970
|
+
) {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
if (variant === undefined) return true;
|
|
974
|
+
return defaultMaxMode === true ? variant.isDefaultMaxConfig === true : variant.isDefaultNonMaxConfig === true;
|
|
975
|
+
}
|
|
976
|
+
if (!details.defaultOn) return false;
|
|
977
|
+
return variant === undefined || variant.isDefaultNonMaxConfig === true || variant.isDefaultMaxConfig === true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function parseCursorContextWindow(value: string | undefined): number | undefined {
|
|
981
|
+
if (!value) return undefined;
|
|
982
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(value.trim());
|
|
983
|
+
if (!match?.[1]) return undefined;
|
|
984
|
+
const amount = Number(match[1]);
|
|
985
|
+
if (!Number.isFinite(amount) || amount <= 0) return undefined;
|
|
986
|
+
const unit = match[2]?.toLowerCase();
|
|
987
|
+
return Math.round(amount * (unit === "m" ? 1_000_000 : unit === "k" ? 1_000 : 1));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function isRichVariantBlocked(details: AvailableModelsResponse_ModelDetails, variant: RichCursorVariant): boolean {
|
|
991
|
+
for (const parameter of variant.parameterValues) {
|
|
992
|
+
const definition = details.parameterDefinitions.find(candidate => candidate.id === parameter.id);
|
|
993
|
+
const values = [
|
|
994
|
+
...(definition?.parameterType?.booleanParameter?.values ?? []),
|
|
995
|
+
...(definition?.parameterType?.enumParameter?.values ?? []),
|
|
996
|
+
];
|
|
997
|
+
const value = values.find(candidate => candidate.value === parameter.value);
|
|
998
|
+
if (value?.blockedByAdminAllowlist === true) return true;
|
|
999
|
+
}
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
273
1002
|
|
|
274
1003
|
function normalizeCursorModels(
|
|
275
1004
|
models: readonly unknown[] | undefined,
|