@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/types/compat/auth-ids.d.ts +2 -2
  3. package/dist/types/compat/axes.d.ts +3 -1
  4. package/dist/types/compat/factory-droid.d.ts +65 -0
  5. package/dist/types/compat/provider-ids.d.ts +1 -1
  6. package/dist/types/compat/resolve.d.ts +8 -2
  7. package/dist/types/compat/types.d.ts +26 -1
  8. package/dist/types/discovery/cursor-proto.d.ts +284 -0
  9. package/dist/types/discovery/cursor.d.ts +12 -6
  10. package/dist/types/discovery/factory-droid.d.ts +28 -0
  11. package/dist/types/discovery/index.d.ts +1 -0
  12. package/dist/types/discovery/protobuf.d.ts +3 -3
  13. package/dist/types/model-manager.d.ts +10 -0
  14. package/dist/types/models.d.ts +5 -1
  15. package/dist/types/provider-models/cache-provider-id.d.ts +2 -1
  16. package/dist/types/provider-models/descriptor-types.d.ts +2 -1
  17. package/dist/types/provider-models/openai-compat.d.ts +21 -0
  18. package/dist/types/provider-models/special.d.ts +2 -0
  19. package/dist/types/types.d.ts +100 -3
  20. package/dist/types/wire/cursor.d.ts +20 -0
  21. package/dist/types/wire/factory-droid.d.ts +45 -0
  22. package/package.json +4 -4
  23. package/src/build.ts +21 -0
  24. package/src/compat/auth-ids.ts +4 -0
  25. package/src/compat/axes.ts +113 -2
  26. package/src/compat/cascade.ts +8 -1
  27. package/src/compat/collapse.ts +3 -0
  28. package/src/compat/factory-droid.ts +170 -0
  29. package/src/compat/provider-ids.ts +2 -0
  30. package/src/compat/resolve.ts +47 -5
  31. package/src/compat/rules/README.md +18 -8
  32. package/src/compat/rules/auth/_order.kdl +2 -2
  33. package/src/compat/rules/auth/factory-droid.kdl +28 -0
  34. package/src/compat/rules/auth/helmcode.kdl +9 -0
  35. package/src/compat/rules/classes/anthropic.kdl +195 -80
  36. package/src/compat/rules/providers/commandcode.kdl +21 -5
  37. package/src/compat/rules/providers/cursor.kdl +29 -17
  38. package/src/compat/rules/providers/factory-droid.kdl +1166 -0
  39. package/src/compat/rules/providers/helmcode.kdl +120 -0
  40. package/src/compat/rules/runtime/behavior.kdl +65 -4
  41. package/src/compat/rules/taxonomy/_collapse.kdl +36 -14
  42. package/src/compat/rules.json +1 -1
  43. package/src/compat/types.ts +27 -1
  44. package/src/discovery/cursor-models.proto +261 -0
  45. package/src/discovery/cursor-proto.ts +471 -0
  46. package/src/discovery/cursor.ts +817 -88
  47. package/src/discovery/devin.ts +1 -3
  48. package/src/discovery/factory-droid.ts +296 -0
  49. package/src/discovery/index.ts +1 -0
  50. package/src/discovery/protobuf.ts +16 -7
  51. package/src/model-manager.ts +13 -0
  52. package/src/models.json +1 -1
  53. package/src/models.ts +13 -0
  54. package/src/provider-models/cache-provider-id.ts +37 -6
  55. package/src/provider-models/descriptor-types.ts +2 -1
  56. package/src/provider-models/descriptors.ts +4 -0
  57. package/src/provider-models/openai-compat.ts +83 -17
  58. package/src/provider-models/special.ts +26 -2
  59. package/src/types.ts +112 -1
  60. package/src/wire/cursor.ts +32 -0
  61. package/src/wire/factory-droid.ts +130 -0
@@ -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 { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema } from "./cursor-proto";
9
- import { create, fromBinary, toBinary } from "./protobuf";
10
-
11
- const CURSOR_DEFAULT_BASE_URL = "https://api2.cursor.sh";
12
- const CURSOR_DEFAULT_CLIENT_VERSION = "cli-2026.02.13-41ac335";
13
- const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
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
- * Fetches Cursor models through `GetUsableModels` and normalizes them into canonical model entries.
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
- * Returns `null` on request/decode failures.
80
- * Returns `[]` only when the endpoint responds successfully with no usable models.
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
- try {
87
- const requestPayload = create(GetUsableModelsRequestSchema, {
88
- customModelIds: normalizeCustomModelIds(options.customModelIds),
89
- });
90
- const body = toBinary(GetUsableModelsRequestSchema, requestPayload);
91
- const baseUrl = (options.baseUrl ?? CURSOR_DEFAULT_BASE_URL).replace(/\/+$/, "");
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
- const responseBuffer = await fetchViaHttp2(baseUrl, body, options, timeoutMs);
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
- if (!responseBuffer) {
96
- return null;
97
- }
98
- const decoded = decodeGetUsableModelsResponse(responseBuffer);
99
- const parsedDecoded = CursorDecodedResponseSchema(decoded);
100
- if (parsedDecoded instanceof type.errors) {
101
- return null;
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
- const references = createCursorReferenceMap();
105
- return normalizeCursorModels(parsedDecoded.models, options.baseUrl, references);
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
- function buildRequestHeaders(options: CursorModelDiscoveryOptions): Record<string, string> {
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
- resolve(null);
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": CURSOR_GET_USABLE_MODELS_PATH,
144
- ...buildRequestHeaders(options),
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
- clearTimeout(timer);
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 decodeGetUsableModelsResponse(payload: Uint8Array) {
204
- if (payload.length === 0) {
205
- return null;
206
- }
207
-
208
- const framedBody = decodeConnectUnaryBody(payload);
209
- if (framedBody) {
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(GetUsableModelsResponseSchema, payload);
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,