@rigelbuild/omp-catalog 18.0.3

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 (117) hide show
  1. package/CHANGELOG.md +1204 -0
  2. package/LICENSE +22 -0
  3. package/README.md +43 -0
  4. package/THIRD-PARTY-NOTICES.txt +22909 -0
  5. package/dist/types/build.d.ts +19 -0
  6. package/dist/types/compat/anthropic.d.ts +18 -0
  7. package/dist/types/compat/apply.d.ts +7 -0
  8. package/dist/types/compat/bedrock.d.ts +3 -0
  9. package/dist/types/compat/devin.d.ts +9 -0
  10. package/dist/types/compat/openai.d.ts +28 -0
  11. package/dist/types/discovery/antigravity.d.ts +70 -0
  12. package/dist/types/discovery/codex.d.ts +36 -0
  13. package/dist/types/discovery/cursor-proto.d.ts +5368 -0
  14. package/dist/types/discovery/cursor.d.ts +23 -0
  15. package/dist/types/discovery/devin-proto.d.ts +1549 -0
  16. package/dist/types/discovery/devin.d.ts +24 -0
  17. package/dist/types/discovery/gemini-cli.d.ts +33 -0
  18. package/dist/types/discovery/gemini.d.ts +25 -0
  19. package/dist/types/discovery/gitlab-duo-workflow.d.ts +30 -0
  20. package/dist/types/discovery/index.d.ts +7 -0
  21. package/dist/types/discovery/openai-compatible.d.ts +89 -0
  22. package/dist/types/discovery/protobuf.d.ts +102 -0
  23. package/dist/types/effort.d.ts +10 -0
  24. package/dist/types/fireworks-model-id.d.ts +23 -0
  25. package/dist/types/hosts.d.ts +169 -0
  26. package/dist/types/identity/bundled.d.ts +3 -0
  27. package/dist/types/identity/classify.d.ts +69 -0
  28. package/dist/types/identity/dialect.d.ts +3 -0
  29. package/dist/types/identity/family.d.ts +239 -0
  30. package/dist/types/identity/id.d.ts +12 -0
  31. package/dist/types/identity/index.d.ts +8 -0
  32. package/dist/types/identity/markers.d.ts +4 -0
  33. package/dist/types/identity/priority.d.ts +1 -0
  34. package/dist/types/identity/reference.d.ts +29 -0
  35. package/dist/types/index.d.ts +19 -0
  36. package/dist/types/model-cache.d.ts +23 -0
  37. package/dist/types/model-manager.d.ts +77 -0
  38. package/dist/types/model-thinking.d.ts +70 -0
  39. package/dist/types/model-tokenizer.d.ts +8 -0
  40. package/dist/types/models.d.ts +14 -0
  41. package/dist/types/openai-pricing.d.ts +17 -0
  42. package/dist/types/provider-models/bundled-references.d.ts +13 -0
  43. package/dist/types/provider-models/cache-provider-id.d.ts +11 -0
  44. package/dist/types/provider-models/descriptor-types.d.ts +76 -0
  45. package/dist/types/provider-models/descriptors.d.ts +505 -0
  46. package/dist/types/provider-models/google.d.ts +29 -0
  47. package/dist/types/provider-models/index.d.ts +7 -0
  48. package/dist/types/provider-models/ollama.d.ts +22 -0
  49. package/dist/types/provider-models/openai-compat.d.ts +646 -0
  50. package/dist/types/provider-models/special.d.ts +53 -0
  51. package/dist/types/types.d.ts +876 -0
  52. package/dist/types/utils.d.ts +20 -0
  53. package/dist/types/variant-collapse.d.ts +137 -0
  54. package/dist/types/wire/alibaba-token-plan.d.ts +22 -0
  55. package/dist/types/wire/codex.d.ts +66 -0
  56. package/dist/types/wire/coreweave.d.ts +12 -0
  57. package/dist/types/wire/gemini-headers.d.ts +62 -0
  58. package/dist/types/wire/github-copilot.d.ts +47 -0
  59. package/dist/types/wire/image-fetchers.d.ts +76 -0
  60. package/package.json +102 -0
  61. package/src/build.ts +103 -0
  62. package/src/compat/anthropic.ts +179 -0
  63. package/src/compat/apply.ts +15 -0
  64. package/src/compat/bedrock.ts +153 -0
  65. package/src/compat/devin.ts +12 -0
  66. package/src/compat/openai.ts +864 -0
  67. package/src/discovery/antigravity.ts +257 -0
  68. package/src/discovery/codex.ts +456 -0
  69. package/src/discovery/cursor-proto.ts +8329 -0
  70. package/src/discovery/cursor.ts +409 -0
  71. package/src/discovery/devin-proto.ts +2037 -0
  72. package/src/discovery/devin.ts +152 -0
  73. package/src/discovery/gemini-cli.ts +198 -0
  74. package/src/discovery/gemini.ts +260 -0
  75. package/src/discovery/gitlab-duo-workflow.ts +859 -0
  76. package/src/discovery/index.ts +7 -0
  77. package/src/discovery/openai-compatible.ts +266 -0
  78. package/src/discovery/protobuf.ts +1073 -0
  79. package/src/effort.ts +18 -0
  80. package/src/fireworks-model-id.ts +50 -0
  81. package/src/hosts.ts +178 -0
  82. package/src/identity/bundled.ts +28 -0
  83. package/src/identity/classify.ts +242 -0
  84. package/src/identity/dialect.ts +42 -0
  85. package/src/identity/family.ts +477 -0
  86. package/src/identity/id.ts +81 -0
  87. package/src/identity/index.ts +8 -0
  88. package/src/identity/markers.ts +49 -0
  89. package/src/identity/priority.ts +59 -0
  90. package/src/identity/reference.ts +167 -0
  91. package/src/index.ts +19 -0
  92. package/src/model-cache.ts +346 -0
  93. package/src/model-manager.ts +683 -0
  94. package/src/model-thinking.ts +925 -0
  95. package/src/model-tokenizer.ts +75 -0
  96. package/src/models.json +304657 -0
  97. package/src/models.json.d.ts +9 -0
  98. package/src/models.ts +108 -0
  99. package/src/openai-pricing.ts +29 -0
  100. package/src/provider-models/bundled-references.ts +87 -0
  101. package/src/provider-models/cache-provider-id.ts +97 -0
  102. package/src/provider-models/descriptor-types.ts +85 -0
  103. package/src/provider-models/descriptors.ts +585 -0
  104. package/src/provider-models/google.ts +117 -0
  105. package/src/provider-models/index.ts +7 -0
  106. package/src/provider-models/ollama.ts +204 -0
  107. package/src/provider-models/openai-compat.ts +6405 -0
  108. package/src/provider-models/special.ts +213 -0
  109. package/src/types.ts +1032 -0
  110. package/src/utils.ts +69 -0
  111. package/src/variant-collapse.ts +1555 -0
  112. package/src/wire/alibaba-token-plan.ts +54 -0
  113. package/src/wire/codex.ts +117 -0
  114. package/src/wire/coreweave.ts +51 -0
  115. package/src/wire/gemini-headers.ts +135 -0
  116. package/src/wire/github-copilot.ts +147 -0
  117. package/src/wire/image-fetchers.ts +159 -0
@@ -0,0 +1,1555 @@
1
+ /**
2
+ * Effort-tier variant collapsing.
3
+ *
4
+ * Some providers expose one logical model as several effort- or
5
+ * thinking-suffixed upstream ids (Antigravity CCA:
6
+ * `gemini-3.5-flash-extra-low`/`-low`, `claude-*`/`claude-*-thinking` pairs;
7
+ * aggregators: `X`/`X-thinking` twins). Collapsing replaces the member specs
8
+ * with one logical spec whose `thinking.effortRouting` records the per-effort
9
+ * upstream wire id; request-time code resolves the outbound id via
10
+ * `resolveWireModelId` and everything local (selection, caching, usage
11
+ * attribution) keys on the logical `id`.
12
+ *
13
+ * Families come from two sources:
14
+ * - Hand tables (`VARIANT_COLLAPSE_TABLES`) for providers whose routing needs
15
+ * curation (Antigravity tier triplets, single-member renames, recycled ids).
16
+ * - `deriveThinkingPairFamilies`: the global automatic rule — any live
17
+ * `X` + `X-thinking` pair (trailing or infix token) collapses into `X`,
18
+ * routing thinking-enabled requests to `X-thinking`. Gated on identical
19
+ * pricing and same api: price-divergent twins are distinct SKUs and stay
20
+ * separate so billing attribution never lies.
21
+ *
22
+ * Family invariants (hold for hand-written and derived tables):
23
+ * - One axis per family. A second id axis (e.g. Cursor's `-fast` service
24
+ * tier) becomes a sibling family, never a second routing dimension.
25
+ * - The collapsed spec inherits non-tier fields from the first present
26
+ * member; members must be cost-homogeneous.
27
+ *
28
+ * `collapseEffortVariants` is pure, deterministic, and idempotent:
29
+ * `collapse(collapse(x))` equals `collapse(x)`, and mixed raw+collapsed input
30
+ * (stale cache rows, previous-snapshot fallbacks) dedupes to the collapsed
31
+ * entry. That makes it safe at every source — discovery, the catalog
32
+ * generator, and the model-manager merge point.
33
+ */
34
+ import { buildCompat, buildModel } from "./build";
35
+ import { Effort, THINKING_EFFORTS } from "./effort";
36
+ import { stripThinkingVariantToken } from "./identity/family";
37
+ import { resolveModelThinking } from "./model-thinking";
38
+ import type { Api, Model, ModelSpec, Provider, ThinkingConfig } from "./types";
39
+
40
+ /**
41
+ * Structural bound for collapse inputs: both raw `ModelSpec`s and built
42
+ * `Model`s qualify. (`Model.compat` is the resolved record, not the sparse
43
+ * config, so the two are not mutually assignable — collapsing never touches
44
+ * `compat`.)
45
+ */
46
+ export type VariantSpecLike = Omit<ModelSpec<Api>, "compat"> & { compat?: unknown };
47
+
48
+ /** One collapsed family: logical id + member wire ids + per-effort routing. */
49
+ export interface EffortVariantFamily {
50
+ /** Collapsed logical id (may equal a member id — e.g. bare/thinking pairs). */
51
+ id: string;
52
+ /** Final display name, no tier marker. */
53
+ name: string;
54
+ /**
55
+ * Member wire ids in priority order. The first member present in the input
56
+ * becomes the collapsed spec's default wire id (`requestModelId`; omitted
57
+ * when it equals the logical id).
58
+ */
59
+ members: readonly string[];
60
+ /**
61
+ * Wire ids upstream no longer serves (e.g. a deployment killed while
62
+ * discovery still advertises it). Fresh collapsing never routes to them,
63
+ * and stale collapsed snapshots (bundled catalog, cache rows,
64
+ * previous-generation fallbacks) get routing/`requestModelId` entries that
65
+ * target them re-pointed through `routing`. Keep retired ids in `members`
66
+ * so the raw upstream spec is still consumed and aliased.
67
+ */
68
+ retiredMembers?: readonly string[];
69
+ /**
70
+ * Per-effort upstream wire id; `"off"` applies when thinking is disabled.
71
+ * Entries whose target member is absent from the input are dropped — those
72
+ * efforts fall back to `requestModelId ?? id`.
73
+ */
74
+ routing: Readonly<Partial<Record<Effort | "off", string>>>;
75
+ /** Explicit capability surface for the collapsed spec — no inference. */
76
+ thinking: Readonly<Omit<ThinkingConfig, "effortRouting" | "suppressWhenOff">>;
77
+ /** Thinking-off requests must explicitly suppress thinking on the wire. */
78
+ suppressWhenOff?: boolean;
79
+ /**
80
+ * Preserve non-off effort routes even when discovery omits the backing member.
81
+ * Used for Cloud Code Assist `X`/`X-thinking` pairs where upstream accepts
82
+ * the `-thinking` wire id but the model-list endpoint may advertise only the
83
+ * bare id.
84
+ */
85
+ preserveAbsentEffortRoutes?: boolean;
86
+ /** Retired/recycled selector ids that alias to this family without being members. */
87
+ extraAliases?: readonly string[];
88
+ }
89
+
90
+ export interface VariantCollapseTable {
91
+ families: readonly EffortVariantFamily[];
92
+ }
93
+
94
+ /** `X` + `X-thinking` hand family: off routes to the bare id, efforts to `-thinking`. */
95
+ function thinkingPair(baseId: string, name: string): EffortVariantFamily {
96
+ return {
97
+ id: baseId,
98
+ name,
99
+ members: [baseId, `${baseId}-thinking`],
100
+ routing: {
101
+ off: baseId,
102
+ [Effort.Minimal]: `${baseId}-thinking`,
103
+ [Effort.Low]: `${baseId}-thinking`,
104
+ [Effort.Medium]: `${baseId}-thinking`,
105
+ [Effort.High]: `${baseId}-thinking`,
106
+ },
107
+ // Thinking-off routes to the non-thinking backing id, where omitting
108
+ // thinkingConfig is already correct — no suppressWhenOff.
109
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
110
+ preserveAbsentEffortRoutes: true,
111
+ };
112
+ }
113
+
114
+ type TierRoutes = Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string>>;
115
+
116
+ /** Devin families with a `-max` sibling: five wire tiers, `low` floor. */
117
+ const DEVIN_FIVE_TIER_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max];
118
+ /** Pre-5.6 Devin GPT families top out at `-xhigh`: four wire tiers, `low` floor. */
119
+ const DEVIN_FOUR_TIER_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh];
120
+
121
+ /**
122
+ * Build one effort-tier family from a tier→wire-id map: routing keeps only the
123
+ * listed efforts, `members` dedupes the targets in tier order, and thinking is
124
+ * `mode: "effort"` (mandatory when the family has no `off` route). Shared by the
125
+ * Devin and Cursor tables, whose per-effort siblings follow the same shape.
126
+ */
127
+ function tierFamily(id: string, name: string, routes: TierRoutes, efforts: readonly Effort[]): EffortVariantFamily {
128
+ const routing: Partial<Record<Effort | "off", string>> = {};
129
+ if (routes.off) routing.off = routes.off;
130
+ for (const effort of efforts) {
131
+ switch (effort) {
132
+ case Effort.Minimal:
133
+ if (routes.minimal) routing[effort] = routes.minimal;
134
+ break;
135
+ case Effort.Low:
136
+ if (routes.low) routing[effort] = routes.low;
137
+ break;
138
+ case Effort.Medium:
139
+ if (routes.medium) routing[effort] = routes.medium;
140
+ break;
141
+ case Effort.High:
142
+ if (routes.high) routing[effort] = routes.high;
143
+ break;
144
+ case Effort.XHigh:
145
+ if (routes.xhigh) routing[effort] = routes.xhigh;
146
+ break;
147
+ case Effort.Max:
148
+ if (routes.max) routing[effort] = routes.max;
149
+ break;
150
+ }
151
+ }
152
+ const members = [
153
+ routes.off,
154
+ routes.minimal,
155
+ routes.low,
156
+ routes.medium,
157
+ routes.high,
158
+ routes.xhigh,
159
+ routes.max,
160
+ ].filter((member, index, items): member is string => typeof member === "string" && items.indexOf(member) === index);
161
+ return {
162
+ id,
163
+ name,
164
+ members,
165
+ routing,
166
+ thinking: {
167
+ mode: "effort",
168
+ efforts,
169
+ ...(routes.off ? undefined : { requiresEffort: true }),
170
+ },
171
+ };
172
+ }
173
+
174
+ /**
175
+ * GPT-5.6 (Luna/Sol/Terra) serves per-tier siblings for the full five-tier
176
+ * `low..max` wire scale in both standard and fast lanes.
177
+ */
178
+ function devinGpt56Families(variant: "luna" | "sol" | "terra", name: string): readonly EffortVariantFamily[] {
179
+ const base = `gpt-5-6-${variant}`;
180
+ return [
181
+ tierFamily(
182
+ base,
183
+ name,
184
+ {
185
+ off: `${base}-none`,
186
+ low: `${base}-low`,
187
+ medium: `${base}-medium`,
188
+ high: `${base}-high`,
189
+ xhigh: `${base}-xhigh`,
190
+ max: `${base}-max`,
191
+ },
192
+ DEVIN_FIVE_TIER_EFFORTS,
193
+ ),
194
+ tierFamily(
195
+ `${base}-fast`,
196
+ `${name} Fast`,
197
+ {
198
+ off: `${base}-none-priority`,
199
+ low: `${base}-low-priority`,
200
+ medium: `${base}-medium-priority`,
201
+ high: `${base}-high-priority`,
202
+ xhigh: `${base}-xhigh-priority`,
203
+ max: `${base}-max-priority`,
204
+ },
205
+ DEVIN_FIVE_TIER_EFFORTS,
206
+ ),
207
+ ];
208
+ }
209
+
210
+ const GEMINI_3_FLASH_FAMILY_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
211
+ const GEMINI_3_PRO_FAMILY_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High];
212
+
213
+ /**
214
+ * Antigravity Cloud Code Assist sends an explicit `thinkingBudget` per tier
215
+ * (verified against captured `daily-cloudcode-pa` requests). Flash uses round
216
+ * budgets; Pro offsets every budget by +1. Minimal mirrors Low (the Antigravity
217
+ * UI exposes Low/Medium/High only) so the effort stays selectable.
218
+ */
219
+ const GEMINI_3_FLASH_FAMILY_BUDGETS: Readonly<Partial<Record<Effort, number>>> = {
220
+ [Effort.Minimal]: 1000,
221
+ [Effort.Low]: 1000,
222
+ [Effort.Medium]: 4000,
223
+ [Effort.High]: 10000,
224
+ };
225
+ const GEMINI_3_PRO_FAMILY_BUDGETS: Readonly<Partial<Record<Effort, number>>> = {
226
+ [Effort.Low]: 1001,
227
+ [Effort.High]: 10001,
228
+ };
229
+
230
+ /**
231
+ * Cloud Code Assist's legacy Gemini 3.5 Flash and 3.1 Pro families use
232
+ * different thinking transports: `google-antigravity` (daily-cloudcode-pa)
233
+ * sends captured `thinkingBudget` values, while `google-gemini-cli`
234
+ * (cloudcode-pa) follows the official Gemini CLI and uses `thinkingLevel`.
235
+ * Gemini 3.6 exposes one wire id per level and uses `thinkingLevel` on both
236
+ * endpoints.
237
+ */
238
+ function geminiFlashFamily(mode: "budget" | "google-level"): EffortVariantFamily {
239
+ const budget = mode === "budget";
240
+ return {
241
+ id: "gemini-3.5-flash",
242
+ name: "Gemini 3.5 Flash",
243
+ members: ["gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3-flash-agent"],
244
+ routing: budget
245
+ ? {
246
+ off: "gemini-3.5-flash-extra-low",
247
+ [Effort.Minimal]: "gemini-3.5-flash-extra-low",
248
+ [Effort.Low]: "gemini-3.5-flash-extra-low",
249
+ [Effort.Medium]: "gemini-3.5-flash-low",
250
+ [Effort.High]: "gemini-3-flash-agent",
251
+ }
252
+ : {
253
+ off: "gemini-3.5-flash-extra-low",
254
+ [Effort.Minimal]: "gemini-3-flash-agent",
255
+ [Effort.Low]: "gemini-3.5-flash-extra-low",
256
+ [Effort.Medium]: "gemini-3.5-flash-extra-low",
257
+ [Effort.High]: "gemini-3.5-flash-low",
258
+ },
259
+ thinking: budget
260
+ ? { mode: "budget", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS, effortBudgets: GEMINI_3_FLASH_FAMILY_BUDGETS }
261
+ : { mode: "google-level", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS },
262
+ suppressWhenOff: true,
263
+ // Retired bare id; the alias only fires when no live model holds it
264
+ // (exact match wins in every resolver).
265
+ extraAliases: ["gemini-3-flash"],
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Gemini 3.6+ Flash exposes one mandatory-reasoning wire id per thinking
271
+ * level. Some generations retain additional discovery-only aliases.
272
+ */
273
+ function geminiLevelFlashFamily(version: "3.6" | "3.7", ...additionalMembers: string[]): EffortVariantFamily {
274
+ const id = `gemini-${version}-flash`;
275
+ return {
276
+ id,
277
+ name: `Gemini ${version} Flash`,
278
+ members: [`${id}-low`, `${id}-medium`, `${id}-high`, ...additionalMembers],
279
+ routing: {
280
+ [Effort.Minimal]: `${id}-low`,
281
+ [Effort.Low]: `${id}-low`,
282
+ [Effort.Medium]: `${id}-medium`,
283
+ [Effort.High]: `${id}-high`,
284
+ },
285
+ thinking: {
286
+ mode: "google-level",
287
+ efforts: GEMINI_3_FLASH_FAMILY_EFFORTS,
288
+ requiresEffort: true,
289
+ },
290
+ };
291
+ }
292
+
293
+ const GEMINI_36_FLASH_FAMILY = geminiLevelFlashFamily("3.6", "gemini-3.6-flash-tiered");
294
+ const GEMINI_37_FLASH_FAMILY = geminiLevelFlashFamily("3.7");
295
+
296
+ function geminiProFamily(mode: "budget" | "google-level"): EffortVariantFamily {
297
+ const budget = mode === "budget";
298
+ return {
299
+ id: "gemini-3.1-pro",
300
+ name: "Gemini 3.1 Pro",
301
+ // High routes to `gemini-pro-agent` — the upstream `gemini-3.1-pro-high`
302
+ // deployment returns INVALID_ARGUMENT on every streamGenerateContent
303
+ // request (both CCA endpoints) while discovery still lists it;
304
+ // `gemini-pro-agent` is the same model ("Gemini 3.1 Pro (High)", same
305
+ // thinking budget/caps) and accepts the identical request body.
306
+ // `gemini-3.1-pro-high` stays a member so the dead raw id is consumed.
307
+ members: ["gemini-3.1-pro-low", "gemini-pro-agent", "gemini-3.1-pro-high"],
308
+ retiredMembers: ["gemini-3.1-pro-high"],
309
+ routing: {
310
+ off: "gemini-3.1-pro-low",
311
+ [Effort.Low]: "gemini-3.1-pro-low",
312
+ [Effort.High]: "gemini-pro-agent",
313
+ },
314
+ thinking: budget
315
+ ? { mode: "budget", efforts: GEMINI_3_PRO_FAMILY_EFFORTS, effortBudgets: GEMINI_3_PRO_FAMILY_BUDGETS }
316
+ : { mode: "google-level", efforts: GEMINI_3_PRO_FAMILY_EFFORTS },
317
+ suppressWhenOff: true,
318
+ };
319
+ }
320
+
321
+ /** CCA families shared verbatim by both providers (transport-agnostic). */
322
+ const SHARED_CCA_FAMILIES: readonly EffortVariantFamily[] = [
323
+ {
324
+ // Legacy static family — covers stale snapshots and caches. Stale ids are
325
+ // unverified against the budget-mode CCA contract; keep them on level.
326
+ id: "gemini-3-pro",
327
+ name: "Gemini 3 Pro",
328
+ members: ["gemini-3-pro-low", "gemini-3-pro-high"],
329
+ routing: {
330
+ off: "gemini-3-pro-low",
331
+ [Effort.Low]: "gemini-3-pro-low",
332
+ [Effort.High]: "gemini-3-pro-high",
333
+ },
334
+ thinking: { mode: "google-level", efforts: GEMINI_3_PRO_FAMILY_EFFORTS },
335
+ suppressWhenOff: true,
336
+ },
337
+ {
338
+ // Rename-only collapse: every effort and off fall back to the wire id.
339
+ id: "gpt-oss-120b",
340
+ name: "GPT-OSS 120B",
341
+ members: ["gpt-oss-120b-medium"],
342
+ routing: {},
343
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
344
+ },
345
+ // Antigravity Cloud Code Assist exposes Claude 4.6 asymmetrically: only the
346
+ // bare `claude-sonnet-4-6` wire id (no `-thinking` twin) and only the
347
+ // `claude-opus-4-6-thinking` wire id (no bare twin). Per-effort thinking is
348
+ // carried in the request body via `thinkingBudget`, so both ids accept on/off
349
+ // requests. Listing both candidates in `members` (priority order) keeps the
350
+ // collapse correct if the backend mix ever rebalances; `retiredMembers`
351
+ // re-points stale collapsed snapshots (bundled catalog rows, cache rows
352
+ // written by prior generations) away from the dead wire id via
353
+ // `reconcileRetiredRouting`.
354
+ {
355
+ id: "claude-sonnet-4-6",
356
+ name: "Claude Sonnet 4.6",
357
+ members: ["claude-sonnet-4-6", "claude-sonnet-4-6-thinking"],
358
+ retiredMembers: ["claude-sonnet-4-6-thinking"],
359
+ routing: {},
360
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
361
+ },
362
+
363
+ {
364
+ id: "claude-opus-4-6",
365
+ name: "Claude Opus 4.6",
366
+ members: ["claude-opus-4-6-thinking", "claude-opus-4-6"],
367
+ retiredMembers: ["claude-opus-4-6"],
368
+ routing: {},
369
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
370
+ },
371
+ thinkingPair("claude-sonnet-4-5", "Claude Sonnet 4.5"),
372
+ thinkingPair("claude-opus-4-5", "Claude Opus 4.5"),
373
+ thinkingPair("gemini-2.5-flash", "Gemini 2.5 Flash"),
374
+ ];
375
+
376
+ /** `google-antigravity` Gemini families, using each generation's native transport. */
377
+ export const ANTIGRAVITY_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
378
+ families: [
379
+ GEMINI_36_FLASH_FAMILY,
380
+ GEMINI_37_FLASH_FAMILY,
381
+ geminiFlashFamily("budget"),
382
+ geminiProFamily("budget"),
383
+ ...SHARED_CCA_FAMILIES,
384
+ ],
385
+ };
386
+
387
+ /** `google-gemini-cli` Gemini families on the official CLI's level transport. */
388
+ export const GEMINI_CLI_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
389
+ families: [
390
+ GEMINI_36_FLASH_FAMILY,
391
+ GEMINI_37_FLASH_FAMILY,
392
+ geminiFlashFamily("google-level"),
393
+ geminiProFamily("google-level"),
394
+ ...SHARED_CCA_FAMILIES,
395
+ ],
396
+ };
397
+ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
398
+ families: [
399
+ tierFamily(
400
+ "claude-opus-5",
401
+ "Claude Opus 5",
402
+ {
403
+ low: "claude-opus-5-low",
404
+ medium: "claude-opus-5-medium",
405
+ high: "claude-opus-5-high",
406
+ xhigh: "claude-opus-5-xhigh",
407
+ max: "claude-opus-5-max",
408
+ },
409
+ DEVIN_FIVE_TIER_EFFORTS,
410
+ ),
411
+ tierFamily(
412
+ "claude-opus-5-fast",
413
+ "Claude Opus 5 Fast",
414
+ {
415
+ low: "claude-opus-5-low-fast",
416
+ medium: "claude-opus-5-medium-fast",
417
+ high: "claude-opus-5-high-fast",
418
+ xhigh: "claude-opus-5-xhigh-fast",
419
+ max: "claude-opus-5-max-fast",
420
+ },
421
+ DEVIN_FIVE_TIER_EFFORTS,
422
+ ),
423
+ tierFamily(
424
+ "claude-fable-5",
425
+ "Claude Fable 5",
426
+ {
427
+ low: "claude-5-fable-low",
428
+ medium: "claude-5-fable-medium",
429
+ high: "claude-5-fable-high",
430
+ xhigh: "claude-5-fable-xhigh",
431
+ max: "claude-5-fable-max",
432
+ },
433
+ DEVIN_FIVE_TIER_EFFORTS,
434
+ ),
435
+ tierFamily(
436
+ "claude-sonnet-5",
437
+ "Claude Sonnet 5",
438
+ {
439
+ low: "claude-sonnet-5-low",
440
+ medium: "claude-sonnet-5-medium",
441
+ high: "claude-sonnet-5-high",
442
+ xhigh: "claude-sonnet-5-xhigh",
443
+ max: "claude-sonnet-5-max",
444
+ },
445
+ DEVIN_FIVE_TIER_EFFORTS,
446
+ ),
447
+ tierFamily(
448
+ "claude-opus-4-7",
449
+ "Claude Opus 4.7",
450
+ {
451
+ low: "claude-opus-4-7-low",
452
+ medium: "claude-opus-4-7-medium",
453
+ high: "claude-opus-4-7-high",
454
+ xhigh: "claude-opus-4-7-xhigh",
455
+ max: "claude-opus-4-7-max",
456
+ },
457
+ DEVIN_FIVE_TIER_EFFORTS,
458
+ ),
459
+ tierFamily(
460
+ "claude-opus-4-7-fast",
461
+ "Claude Opus 4.7 Fast",
462
+ {
463
+ low: "claude-opus-4-7-low-fast",
464
+ medium: "claude-opus-4-7-medium-fast",
465
+ high: "claude-opus-4-7-high-fast",
466
+ xhigh: "claude-opus-4-7-xhigh-fast",
467
+ max: "claude-opus-4-7-max-fast",
468
+ },
469
+ DEVIN_FIVE_TIER_EFFORTS,
470
+ ),
471
+ tierFamily(
472
+ "claude-opus-4-8",
473
+ "Claude Opus 4.8",
474
+ {
475
+ low: "claude-opus-4-8-low",
476
+ medium: "claude-opus-4-8-medium",
477
+ high: "claude-opus-4-8-high",
478
+ xhigh: "claude-opus-4-8-xhigh",
479
+ max: "claude-opus-4-8-max",
480
+ },
481
+ DEVIN_FIVE_TIER_EFFORTS,
482
+ ),
483
+ tierFamily(
484
+ "claude-opus-4-8-fast",
485
+ "Claude Opus 4.8 Fast",
486
+ {
487
+ low: "claude-opus-4-8-low-fast",
488
+ medium: "claude-opus-4-8-medium-fast",
489
+ high: "claude-opus-4-8-high-fast",
490
+ xhigh: "claude-opus-4-8-xhigh-fast",
491
+ max: "claude-opus-4-8-max-fast",
492
+ },
493
+ DEVIN_FIVE_TIER_EFFORTS,
494
+ ),
495
+ tierFamily(
496
+ "gpt-5-2",
497
+ "GPT-5.2",
498
+ {
499
+ off: "MODEL_GPT_5_2_NONE",
500
+ low: "MODEL_GPT_5_2_LOW",
501
+ medium: "MODEL_GPT_5_2_MEDIUM",
502
+ high: "MODEL_GPT_5_2_HIGH",
503
+ xhigh: "MODEL_GPT_5_2_XHIGH",
504
+ },
505
+ DEVIN_FOUR_TIER_EFFORTS,
506
+ ),
507
+ tierFamily(
508
+ "gpt-5-3-codex",
509
+ "GPT-5.3 Codex",
510
+ {
511
+ low: "gpt-5-3-codex-low",
512
+ medium: "gpt-5-3-codex-medium",
513
+ high: "gpt-5-3-codex-high",
514
+ xhigh: "gpt-5-3-codex-xhigh",
515
+ },
516
+ DEVIN_FOUR_TIER_EFFORTS,
517
+ ),
518
+ tierFamily(
519
+ "gpt-5-3-codex-fast",
520
+ "GPT-5.3 Codex Fast",
521
+ {
522
+ low: "gpt-5-3-codex-low-priority",
523
+ medium: "gpt-5-3-codex-medium-priority",
524
+ high: "gpt-5-3-codex-high-priority",
525
+ xhigh: "gpt-5-3-codex-xhigh-priority",
526
+ },
527
+ DEVIN_FOUR_TIER_EFFORTS,
528
+ ),
529
+ tierFamily(
530
+ "gpt-5-4",
531
+ "GPT-5.4",
532
+ {
533
+ off: "gpt-5-4-none",
534
+ low: "gpt-5-4-low",
535
+ medium: "gpt-5-4-medium",
536
+ high: "gpt-5-4-high",
537
+ xhigh: "gpt-5-4-xhigh",
538
+ },
539
+ DEVIN_FOUR_TIER_EFFORTS,
540
+ ),
541
+ tierFamily(
542
+ "gpt-5-4-fast",
543
+ "GPT-5.4 Fast",
544
+ {
545
+ off: "gpt-5-4-none-priority",
546
+ low: "gpt-5-4-low-priority",
547
+ medium: "gpt-5-4-medium-priority",
548
+ high: "gpt-5-4-high-priority",
549
+ xhigh: "gpt-5-4-xhigh-priority",
550
+ },
551
+ DEVIN_FOUR_TIER_EFFORTS,
552
+ ),
553
+ tierFamily(
554
+ "gpt-5-4-mini",
555
+ "GPT-5.4 Mini",
556
+ {
557
+ low: "gpt-5-4-mini-low",
558
+ medium: "gpt-5-4-mini-medium",
559
+ high: "gpt-5-4-mini-high",
560
+ xhigh: "gpt-5-4-mini-xhigh",
561
+ },
562
+ DEVIN_FOUR_TIER_EFFORTS,
563
+ ),
564
+ tierFamily(
565
+ "gpt-5-5",
566
+ "GPT-5.5",
567
+ {
568
+ off: "gpt-5-5-none",
569
+ low: "gpt-5-5-low",
570
+ medium: "gpt-5-5-medium",
571
+ high: "gpt-5-5-high",
572
+ xhigh: "gpt-5-5-xhigh",
573
+ },
574
+ DEVIN_FOUR_TIER_EFFORTS,
575
+ ),
576
+ tierFamily(
577
+ "gpt-5-5-fast",
578
+ "GPT-5.5 Fast",
579
+ {
580
+ off: "gpt-5-5-none-priority",
581
+ low: "gpt-5-5-low-priority",
582
+ medium: "gpt-5-5-medium-priority",
583
+ high: "gpt-5-5-high-priority",
584
+ xhigh: "gpt-5-5-xhigh-priority",
585
+ },
586
+ DEVIN_FOUR_TIER_EFFORTS,
587
+ ),
588
+ ...devinGpt56Families("luna", "GPT-5.6 Luna"),
589
+ ...devinGpt56Families("sol", "GPT-5.6 Sol"),
590
+ ...devinGpt56Families("terra", "GPT-5.6 Terra"),
591
+ tierFamily(
592
+ "kimi-k3",
593
+ "Kimi K3",
594
+ {
595
+ low: "kimi-k3-low",
596
+ high: "kimi-k3-high",
597
+ max: "kimi-k3-max",
598
+ },
599
+ [Effort.Low, Effort.High, Effort.Max],
600
+ ),
601
+ tierFamily(
602
+ "swe-1-7",
603
+ "SWE-1.7",
604
+ {
605
+ medium: "swe-1-7-medium",
606
+ max: "swe-1-7",
607
+ },
608
+ [Effort.Medium, Effort.Max],
609
+ ),
610
+ tierFamily(
611
+ "grok-4-5",
612
+ "Grok 4.5",
613
+ {
614
+ low: "grok-4-5-low",
615
+ medium: "grok-4-5-medium",
616
+ high: "grok-4-5-high",
617
+ },
618
+ [Effort.Low, Effort.Medium, Effort.High],
619
+ ),
620
+ tierFamily(
621
+ "inkling",
622
+ "Inkling",
623
+ {
624
+ off: "inkling-none",
625
+ low: "inkling-low",
626
+ medium: "inkling-medium",
627
+ high: "inkling-high",
628
+ xhigh: "inkling-xhigh",
629
+ max: "inkling-max",
630
+ },
631
+ DEVIN_FIVE_TIER_EFFORTS,
632
+ ),
633
+ tierFamily(
634
+ "gemini-3-1-pro",
635
+ "Gemini 3.1 Pro",
636
+ {
637
+ low: "gemini-3-1-pro-low",
638
+ high: "gemini-3-1-pro-high",
639
+ },
640
+ [Effort.Low, Effort.High],
641
+ ),
642
+ tierFamily(
643
+ "gemini-3-5-flash",
644
+ "Gemini 3.5 Flash",
645
+ {
646
+ minimal: "gemini-3-5-flash-minimal",
647
+ low: "gemini-3-5-flash-low",
648
+ medium: "gemini-3-5-flash-medium",
649
+ high: "gemini-3-5-flash-high",
650
+ },
651
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
652
+ ),
653
+ tierFamily(
654
+ "gemini-3-6-flash",
655
+ "Gemini 3.6 Flash",
656
+ {
657
+ minimal: "gemini-3-6-flash-minimal",
658
+ low: "gemini-3-6-flash-low",
659
+ medium: "gemini-3-6-flash-medium",
660
+ high: "gemini-3-6-flash-high",
661
+ },
662
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
663
+ ),
664
+ tierFamily(
665
+ "gemini-3-flash",
666
+ "Gemini 3 Flash",
667
+ {
668
+ minimal: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
669
+ low: "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
670
+ medium: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
671
+ high: "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
672
+ },
673
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
674
+ ),
675
+ // GLM-5.2 200K — only the base wire UID `glm-5-2` is free on Devin's
676
+ // Coding Plan (verified via streamDevin: `glm-5-2-none` and `glm-5-2-max`
677
+ // both return "weekly usage quota exhausted" while `glm-5-2` streams
678
+ // successfully). Route every effort to `glm-5-2` so the collapsed entry
679
+ // is always free; include the paid 200K variants as members so they are
680
+ // hidden from the model list. The 1M-context variants stay as separate
681
+ // paid entries (collapsed below).
682
+ {
683
+ id: "glm-5-2",
684
+ name: "GLM-5.2",
685
+ members: ["glm-5-2", "glm-5-2-none", "glm-5-2-max"],
686
+ routing: {
687
+ [Effort.High]: "glm-5-2",
688
+ [Effort.XHigh]: "glm-5-2",
689
+ },
690
+ thinking: {
691
+ mode: "effort",
692
+ efforts: [Effort.High, Effort.XHigh],
693
+ requiresEffort: true,
694
+ },
695
+ },
696
+ // GLM-5.2 1M — paid variants that consume weekly quota. Collapse the
697
+ // three 1M-context variants into one entry with proper effort routing.
698
+ tierFamily(
699
+ "glm-5-2-1m",
700
+ "GLM-5.2 1M",
701
+ {
702
+ off: "glm-5-2-none-1m",
703
+ high: "glm-5-2-1m",
704
+ xhigh: "glm-5-2-max-1m",
705
+ },
706
+ [Effort.High, Effort.XHigh],
707
+ ),
708
+ ],
709
+ };
710
+
711
+ /** Cursor's Grok tier tokens equal the effort id, so routing is a direct map. */
712
+ const CURSOR_GROK_45_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High];
713
+ const CURSOR_GROK_46_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh];
714
+ /** Cursor GPT-5.6 (Luna/Sol/Terra) serves the full five-tier `low..max` scale. */
715
+ const CURSOR_GPT_56_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max];
716
+
717
+ /**
718
+ * Cursor serves Grok 4.5/4.6 as per-effort sibling ids
719
+ * (`cursor-grok-4.6-low|-medium|-high|-xhigh`) alongside a parallel `-fast`
720
+ * service-tier lane (`-low-fast`, …). Each lane collapses into its own logical
721
+ * model — the `-fast` axis is a sibling family, never a second routing
722
+ * dimension — with effort routing onto the live sibling wire ids.
723
+ */
724
+ function cursorGrokFamilies(version: "4.5" | "4.6", efforts: readonly Effort[]): readonly EffortVariantFamily[] {
725
+ const build = (fast: boolean): EffortVariantFamily => {
726
+ const suffix = fast ? "-fast" : "";
727
+ const routes: TierRoutes = {};
728
+ for (const effort of efforts) {
729
+ routes[effort] = `cursor-grok-${version}-${effort}${suffix}`;
730
+ }
731
+ return tierFamily(`cursor-grok-${version}${suffix}`, `Grok ${version}${fast ? " Fast" : ""}`, routes, efforts);
732
+ };
733
+ return [build(false), build(true)];
734
+ }
735
+
736
+ /**
737
+ * Cursor serves GPT-5.6 (Luna/Sol/Terra) as per-tier sibling ids
738
+ * (`gpt-5.6-luna-none|-low|-medium|-high|-xhigh|-max`) with a parallel `-fast`
739
+ * service-tier lane (`gpt-5.6-luna-high-fast`, …). Same shape as Devin's
740
+ * `devinGpt56Families`, but cursor keys the version with a dot, marks the
741
+ * thinking-off tier `-none`, and names the fast lane with `-fast` (Devin uses
742
+ * `-priority`). Each lane collapses into its own logical model — `-fast` is a
743
+ * sibling SKU, never a second routing dimension — while the 1M / Max Mode SKU
744
+ * stays a separate row (handled by discovery's context-window resolution).
745
+ */
746
+ function cursorGpt56Families(variant: "luna" | "sol" | "terra", name: string): readonly EffortVariantFamily[] {
747
+ const build = (fast: boolean): EffortVariantFamily => {
748
+ const suffix = fast ? "-fast" : "";
749
+ const base = `gpt-5.6-${variant}`;
750
+ return tierFamily(
751
+ `${base}${suffix}`,
752
+ `${name}${fast ? " Fast" : ""}`,
753
+ {
754
+ off: `${base}-none${suffix}`,
755
+ low: `${base}-low${suffix}`,
756
+ medium: `${base}-medium${suffix}`,
757
+ high: `${base}-high${suffix}`,
758
+ xhigh: `${base}-xhigh${suffix}`,
759
+ max: `${base}-max${suffix}`,
760
+ },
761
+ CURSOR_GPT_56_EFFORTS,
762
+ );
763
+ };
764
+ return [build(false), build(true)];
765
+ }
766
+
767
+ /** `cursor` per-effort sibling families collapsed per service-tier lane: Grok 4.5/4.6 plus GPT-5.6 Luna/Sol/Terra. */
768
+ export const CURSOR_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
769
+ families: [
770
+ ...cursorGrokFamilies("4.5", CURSOR_GROK_45_EFFORTS),
771
+ ...cursorGrokFamilies("4.6", CURSOR_GROK_46_EFFORTS),
772
+ ...cursorGpt56Families("luna", "GPT-5.6 Luna"),
773
+ ...cursorGpt56Families("sol", "GPT-5.6 Sol"),
774
+ ...cursorGpt56Families("terra", "GPT-5.6 Terra"),
775
+ ],
776
+ };
777
+
778
+ type CursorTierToken = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
779
+
780
+ interface CursorTierMember<TSpec extends VariantSpecLike> {
781
+ baseId: string;
782
+ fast: boolean;
783
+ spec: TSpec;
784
+ tier: CursorTierToken;
785
+ }
786
+
787
+ const CURSOR_TIER_ID_PATTERN = /^(.+?)-(extra-high|none|minimal|low|medium|high|xhigh|max)(-fast)?$/;
788
+ const CURSOR_TIER_BASE_PATTERN = /-(extra-high|none|minimal|low|medium|high|xhigh|max)$/;
789
+ const CURSOR_THINKING_TOKEN_PATTERN = /(^|-)thinking($|-)/;
790
+ const CURSOR_TIER_BY_TOKEN: Readonly<Record<string, CursorTierToken | undefined>> = {
791
+ none: "none",
792
+ minimal: "minimal",
793
+ low: "low",
794
+ medium: "medium",
795
+ high: "high",
796
+ "extra-high": "xhigh",
797
+ xhigh: "xhigh",
798
+ max: "max",
799
+ };
800
+
801
+ /** Whether an existing logical row already routes every live member in `members`. */
802
+ function collapsedCursorLogicalMatches<TSpec extends VariantSpecLike>(
803
+ spec: TSpec,
804
+ members: readonly CursorTierMember<TSpec>[],
805
+ ): boolean {
806
+ const routing = spec.thinking?.effortRouting;
807
+ if (!routing) return false;
808
+ for (const member of members) {
809
+ if (spec.requestModelId === member.spec.id) continue;
810
+ let matched = false;
811
+ for (const effort of VARIANT_ROUTING_KEYS) {
812
+ if (routing[effort] === member.spec.id) {
813
+ matched = true;
814
+ break;
815
+ }
816
+ }
817
+ if (!matched) return false;
818
+ }
819
+ return true;
820
+ }
821
+
822
+ /**
823
+ * Derive safe Cursor per-effort families from live wire ids. A family is
824
+ * intentionally left expanded when its base is an independent live SKU, any
825
+ * member already has a thinking ladder, member metadata differs, or a tier
826
+ * token is also part of a product name.
827
+ */
828
+ function deriveCursorEffortFamilies<TSpec extends VariantSpecLike>(specs: readonly TSpec[]): EffortVariantFamily[] {
829
+ const byId = new Map<string, TSpec>();
830
+ const groups = new Map<string, CursorTierMember<TSpec>[]>();
831
+ const candidateBases = new Set<string>();
832
+
833
+ for (const spec of specs) {
834
+ if (!byId.has(spec.id)) byId.set(spec.id, spec);
835
+ const match = CURSOR_TIER_ID_PATTERN.exec(spec.id);
836
+ if (!match) continue;
837
+ const baseId = match[1];
838
+ const tier = CURSOR_TIER_BY_TOKEN[match[2] ?? ""];
839
+ const fast = match[3] !== undefined;
840
+ if (!baseId || !tier) continue;
841
+ const member = { baseId, fast, spec, tier };
842
+ const key = `${baseId}\0${fast ? "fast" : "standard"}`;
843
+ const group = groups.get(key);
844
+ if (group) {
845
+ group.push(member);
846
+ } else {
847
+ groups.set(key, [member]);
848
+ }
849
+ candidateBases.add(baseId);
850
+ }
851
+
852
+ const unsafeBases = new Set<string>();
853
+ for (const group of groups.values()) {
854
+ const first = group[0];
855
+ if (!first) continue;
856
+ const { baseId } = first;
857
+ const standardGroup = groups.get(`${baseId}\0standard`) ?? [];
858
+ const standardBase = byId.get(baseId);
859
+ const laneBase = byId.get(`${baseId}${first.fast ? "-fast" : ""}`);
860
+ const independentStandardBase =
861
+ standardBase !== undefined && !collapsedCursorLogicalMatches(standardBase, standardGroup);
862
+ const independentLaneBase = laneBase !== undefined && !collapsedCursorLogicalMatches(laneBase, group);
863
+ if (
864
+ independentStandardBase ||
865
+ independentLaneBase ||
866
+ new Set(group.map(member => member.tier)).size !== group.length ||
867
+ CURSOR_TIER_BASE_PATTERN.test(baseId) ||
868
+ CURSOR_THINKING_TOKEN_PATTERN.test(baseId) ||
869
+ candidateBases.has(`${baseId}-thinking`) ||
870
+ byId.has(`${baseId}-thinking`) ||
871
+ byId.has(`${baseId}-thinking-fast`) ||
872
+ byId.has(`${baseId}-fast-thinking`) ||
873
+ group.some(member => member.spec.thinking !== undefined || member.spec.requestModelId !== undefined) ||
874
+ group.some(member => candidateBases.has(`${baseId}-${member.tier}`))
875
+ ) {
876
+ unsafeBases.add(baseId);
877
+ }
878
+ }
879
+
880
+ const families: EffortVariantFamily[] = [];
881
+ for (const group of groups.values()) {
882
+ const first = group[0];
883
+ if (!first || group.length < 2 || unsafeBases.has(first.baseId)) continue;
884
+ if (
885
+ group.some(
886
+ member =>
887
+ member.spec.api !== first.spec.api ||
888
+ member.spec.baseUrl !== first.spec.baseUrl ||
889
+ member.spec.contextWindow !== first.spec.contextWindow ||
890
+ member.spec.maxTokens !== first.spec.maxTokens ||
891
+ member.spec.cursorMaxMode !== first.spec.cursorMaxMode ||
892
+ !Bun.deepEquals(member.spec.cost, first.spec.cost) ||
893
+ !Bun.deepEquals(member.spec.compat, first.spec.compat),
894
+ )
895
+ ) {
896
+ continue;
897
+ }
898
+
899
+ const routes: TierRoutes = {};
900
+ for (const member of group) {
901
+ if (member.tier === "none") {
902
+ routes.off = member.spec.id;
903
+ } else {
904
+ routes[member.tier] = member.spec.id;
905
+ }
906
+ }
907
+ const efforts = THINKING_EFFORTS.filter(effort => routes[effort] !== undefined);
908
+ if (efforts.length === 0) continue;
909
+ const strippedName = first.spec.name
910
+ .replace(/\s+(extra-high|none|minimal|low|medium|high|xhigh|max)(\s+fast)?$/i, "")
911
+ .trim();
912
+ const baseName = strippedName === first.spec.id ? first.baseId : strippedName || first.baseId;
913
+ const suffix = first.fast ? "-fast" : "";
914
+ families.push(tierFamily(`${first.baseId}${suffix}`, `${baseName}${first.fast ? " Fast" : ""}`, routes, efforts));
915
+ }
916
+ return families;
917
+ }
918
+
919
+ /** Provider id → hand collapse table. The CCA providers diverge on thinking transport. */
920
+ export const VARIANT_COLLAPSE_TABLES: Readonly<Record<string, VariantCollapseTable>> = {
921
+ "google-antigravity": ANTIGRAVITY_VARIANT_COLLAPSE_TABLE,
922
+ "google-gemini-cli": GEMINI_CLI_VARIANT_COLLAPSE_TABLE,
923
+ devin: DEVIN_VARIANT_COLLAPSE_TABLE,
924
+ cursor: CURSOR_VARIANT_COLLAPSE_TABLE,
925
+ };
926
+
927
+ /**
928
+ * The global automatic rule: derive an `X` + `X-thinking` family for every
929
+ * pair where both ids are live in `specs` (trailing or infix token). Gates:
930
+ * - both members share the same `api`,
931
+ * - known pricing must match — all-zero cost rows count as unknown
932
+ * (aggregators routinely ship them), but twins that BOTH carry real,
933
+ * differing prices are distinct SKUs and never merge,
934
+ * - ids claimed by the provider's hand `table` are skipped (curation wins).
935
+ * The capability surface prefers the thinking member's metadata, then the
936
+ * bare member's, then the canonical deriver (aggregators often ship
937
+ * `reasoning: false` and no thinking config on the twin), then a budget
938
+ * default. `off` routes to the bare id; every supported effort routes to the
939
+ * thinking id.
940
+ */
941
+ export function deriveThinkingPairFamilies<TSpec extends VariantSpecLike>(
942
+ specs: readonly TSpec[],
943
+ table?: VariantCollapseTable,
944
+ ): EffortVariantFamily[] {
945
+ const byId = new Map<string, TSpec>();
946
+ for (const spec of specs) {
947
+ if (!byId.has(spec.id)) byId.set(spec.id, spec);
948
+ }
949
+ const claimed = table ? getAliasIndex(table) : undefined;
950
+ const families: EffortVariantFamily[] = [];
951
+ for (const spec of specs) {
952
+ const baseId = stripThinkingVariantToken(spec.id);
953
+ if (baseId === undefined || baseId === spec.id) continue;
954
+ const base = byId.get(baseId);
955
+ if (!base) continue;
956
+ if (claimed) {
957
+ const forward = claimed.forward;
958
+ if (
959
+ forward.has(spec.id.toLowerCase()) ||
960
+ forward.has(baseId.toLowerCase()) ||
961
+ claimed.familyIds.has(spec.id) ||
962
+ claimed.familyIds.has(baseId)
963
+ ) {
964
+ continue;
965
+ }
966
+ }
967
+ if (spec.api !== base.api) continue;
968
+ const specPriced = spec.cost.input !== 0 || spec.cost.output !== 0;
969
+ const basePriced = base.cost.input !== 0 || base.cost.output !== 0;
970
+ if (
971
+ specPriced &&
972
+ basePriced &&
973
+ (spec.cost.input !== base.cost.input ||
974
+ spec.cost.output !== base.cost.output ||
975
+ spec.cost.cacheRead !== base.cost.cacheRead ||
976
+ spec.cost.cacheWrite !== base.cost.cacheWrite)
977
+ ) {
978
+ continue;
979
+ }
980
+ const surface = derivePairThinkingSurface(spec, base);
981
+ const routing: Partial<Record<Effort | "off", string>> = { off: base.id };
982
+ for (const effort of surface.efforts) {
983
+ routing[effort] = spec.id;
984
+ }
985
+ families.push({
986
+ id: base.id,
987
+ name: base.name,
988
+ members: [base.id, spec.id],
989
+ routing,
990
+ thinking: surface,
991
+ });
992
+ }
993
+ return families;
994
+ }
995
+
996
+ const DEFAULT_PAIR_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
997
+
998
+ /**
999
+ * Surface fallback chain: thinking member → bare member → canonical deriver →
1000
+ * budget default. `requiresEffort` is dropped from every source: the COLLAPSED
1001
+ * pair can disable thinking (off routes to the bare backing id), even though
1002
+ * the thinking member alone cannot.
1003
+ */
1004
+ function derivePairThinkingSurface(
1005
+ thinkingSpec: VariantSpecLike,
1006
+ baseSpec: VariantSpecLike,
1007
+ ): Omit<ThinkingConfig, "effortRouting" | "suppressWhenOff" | "requiresEffort"> {
1008
+ const baked = thinkingSpec.thinking ?? baseSpec.thinking;
1009
+ if (baked && baked.efforts.length > 0) {
1010
+ const { effortRouting: _routing, suppressWhenOff: _suppress, requiresEffort: _required, ...surface } = baked;
1011
+ return surface;
1012
+ }
1013
+ const derived = resolveModelThinking(
1014
+ { ...(thinkingSpec as unknown as ModelSpec<Api>), reasoning: true, thinking: undefined },
1015
+ buildCompat(thinkingSpec as unknown as ModelSpec<Api>),
1016
+ );
1017
+ if (derived && derived.efforts.length > 0) {
1018
+ const { effortRouting: _dRouting, suppressWhenOff: _dSuppress, requiresEffort: _dRequired, ...surface } = derived;
1019
+ return surface;
1020
+ }
1021
+ return { mode: "budget", efforts: DEFAULT_PAIR_EFFORTS };
1022
+ }
1023
+
1024
+ /**
1025
+ * True when `spec` is the output of collapsing rather than a raw upstream
1026
+ * member. `thinking.effortRouting` is written only by collapsing; the
1027
+ * `requestModelId` arm is scoped to the provider's hand-table family ids so
1028
+ * unrelated carriers (GitHub Copilot `-1m` context variants) never match.
1029
+ */
1030
+ export function isVariantCollapsedSpec(spec: VariantSpecLike): boolean {
1031
+ if (spec.thinking?.effortRouting !== undefined) {
1032
+ return true;
1033
+ }
1034
+ if (spec.requestModelId === undefined) {
1035
+ return false;
1036
+ }
1037
+ const table = VARIANT_COLLAPSE_TABLES[spec.provider];
1038
+ return table !== undefined && getAliasIndex(table).familyIds.has(spec.id);
1039
+ }
1040
+
1041
+ /**
1042
+ * Re-point a stale collapsed spec whose `requestModelId` or routing still
1043
+ * targets a retired wire id. Collapsed snapshots (bundled catalog, cache
1044
+ * rows, previous-generation fallbacks) pass through collapsing untouched, so
1045
+ * a hand-table routing fix would otherwise never reach them. Only retired
1046
+ * targets are rewritten — presence-filtered routing decisions from live
1047
+ * discovery stay authoritative for everything else. Per retired entry the
1048
+ * table's route for that effort wins, then the off/first-live-member wire id,
1049
+ * then the route is dropped (falls back to `requestModelId ?? id`). Returns
1050
+ * `spec` by reference when nothing targets a retired id.
1051
+ */
1052
+ function reconcileRetiredRouting<TSpec extends VariantSpecLike>(
1053
+ spec: TSpec,
1054
+ family: EffortVariantFamily,
1055
+ retired: ReadonlySet<string>,
1056
+ ): TSpec {
1057
+ const routing = spec.thinking?.effortRouting;
1058
+ const requestRetired = spec.requestModelId !== undefined && retired.has(spec.requestModelId);
1059
+ let routingRetired = false;
1060
+ if (routing !== undefined) {
1061
+ for (const key in routing) {
1062
+ const target = routing[key as Effort | "off"];
1063
+ if (target !== undefined && retired.has(target)) {
1064
+ routingRetired = true;
1065
+ break;
1066
+ }
1067
+ }
1068
+ }
1069
+ if (!requestRetired && !routingRetired) return spec;
1070
+
1071
+ const offTarget = family.routing.off;
1072
+ const fallbackWireId =
1073
+ offTarget !== undefined && !retired.has(offTarget) ? offTarget : family.members.find(id => !retired.has(id));
1074
+ const next: TSpec = { ...spec };
1075
+ if (routingRetired && routing !== undefined) {
1076
+ const nextRouting: Partial<Record<Effort | "off", string>> = {};
1077
+ for (const key in routing) {
1078
+ const effortKey = key as Effort | "off";
1079
+ const target = routing[effortKey];
1080
+ if (target === undefined) continue;
1081
+ if (!retired.has(target)) {
1082
+ nextRouting[effortKey] = target;
1083
+ continue;
1084
+ }
1085
+ const tableTarget = family.routing[effortKey];
1086
+ if (tableTarget !== undefined && !retired.has(tableTarget)) {
1087
+ nextRouting[effortKey] = tableTarget;
1088
+ } else if (fallbackWireId !== undefined) {
1089
+ nextRouting[effortKey] = fallbackWireId;
1090
+ }
1091
+ }
1092
+ next.thinking = { ...(spec.thinking as ThinkingConfig), effortRouting: nextRouting };
1093
+ }
1094
+ if (requestRetired) {
1095
+ if (fallbackWireId !== undefined && fallbackWireId !== spec.id) {
1096
+ next.requestModelId = fallbackWireId;
1097
+ } else {
1098
+ delete next.requestModelId;
1099
+ }
1100
+ }
1101
+ return next;
1102
+ }
1103
+
1104
+ /**
1105
+ * Refresh a collapsed snapshot's thinking surface in place. Bundled catalog and
1106
+ * prev-generation snapshots freeze a family's transport, budgets, and routing;
1107
+ * discovery emits the canonical id but the exact-id merge never overwrites a
1108
+ * stale `family.id` row (e.g. `gemini-3.1-pro`) nor a recycled `extraAliases`
1109
+ * row (e.g. `gemini-3-flash`). This re-applies the hand-table family's thinking,
1110
+ * routing, and default wire id while keeping the spec id (load-bearing for exact
1111
+ * selectors and bundled lookups). Returns `spec` by reference when unchanged.
1112
+ */
1113
+ function refreshCollapsedThinking<TSpec extends VariantSpecLike>(
1114
+ spec: TSpec,
1115
+ family: EffortVariantFamily,
1116
+ retired: ReadonlySet<string> | undefined,
1117
+ ): TSpec {
1118
+ // Scope snapshot self-heal to families carrying a curated per-effort budget
1119
+ // contract (Antigravity gemini-3.x). Their routing targets are all verified
1120
+ // live, so rebuilding routing here is safe; families without `effortBudgets`
1121
+ // (derived `X`/`X-thinking` pairs, claude pairs) keep their presence-filtered
1122
+ // snapshot routing untouched.
1123
+ if (!spec.reasoning || family.thinking.effortBudgets === undefined) return spec;
1124
+ const routing: Partial<Record<Effort | "off", string>> = {};
1125
+ let hasRouting = false;
1126
+ for (const effortKey in family.routing) {
1127
+ const target = family.routing[effortKey as Effort | "off"];
1128
+ if (target !== undefined && !retired?.has(target)) {
1129
+ routing[effortKey as Effort | "off"] = target;
1130
+ hasRouting = true;
1131
+ }
1132
+ }
1133
+ const thinking: ThinkingConfig = { ...family.thinking };
1134
+ if (hasRouting) thinking.effortRouting = routing;
1135
+ if (family.suppressWhenOff) thinking.suppressWhenOff = true;
1136
+ const offTarget = family.routing.off;
1137
+ const requestModelId =
1138
+ offTarget !== undefined && !retired?.has(offTarget) && offTarget !== spec.id ? offTarget : spec.requestModelId;
1139
+ if (Bun.deepEquals(thinking, spec.thinking) && requestModelId === spec.requestModelId) {
1140
+ return spec;
1141
+ }
1142
+ return { ...spec, thinking, ...(requestModelId !== undefined ? { requestModelId } : {}) };
1143
+ }
1144
+
1145
+ /**
1146
+ * Collapse every family in `table` found in `specs`. Non-member specs pass
1147
+ * through verbatim (by reference), order preserved; the collapsed spec
1148
+ * replaces the first occurrence of its family.
1149
+ */
1150
+ export function collapseEffortVariants<TSpec extends VariantSpecLike>(
1151
+ specs: readonly TSpec[],
1152
+ table: VariantCollapseTable,
1153
+ ): TSpec[] {
1154
+ const byId = new Map<string, TSpec>();
1155
+ for (const spec of specs) {
1156
+ if (!byId.has(spec.id)) byId.set(spec.id, spec);
1157
+ }
1158
+
1159
+ /** family id → spec to emit at the family's first occurrence. */
1160
+ const replacement = new Map<string, TSpec>();
1161
+ /** spec ids that belong to a touched family (members + logical id). */
1162
+ const familyIdBySpecId = new Map<string, string>();
1163
+
1164
+ for (const family of table.families) {
1165
+ const retired =
1166
+ family.retiredMembers !== undefined && family.retiredMembers.length > 0
1167
+ ? new Set(family.retiredMembers)
1168
+ : undefined;
1169
+ const existing = byId.get(family.id);
1170
+ const existingCollapsed =
1171
+ existing !== undefined &&
1172
+ (existing.requestModelId !== undefined || existing.thinking?.effortRouting !== undefined);
1173
+ const reconciled =
1174
+ existing !== undefined && existingCollapsed && retired !== undefined
1175
+ ? reconcileRetiredRouting(existing, family, retired)
1176
+ : existing;
1177
+ const rawPresent = family.members.filter(id => byId.has(id) && !(id === family.id && existingCollapsed));
1178
+ if (rawPresent.length === 0) {
1179
+ // Inert (no members) or already collapsed (pass-through). A stale
1180
+ // family.id-keyed snapshot is refreshed in place from the current
1181
+ // hand-table family (transport/budgets/routing); retired targets drop.
1182
+ // Recycled extraAliases rows are healed in a later pass.
1183
+ const refreshed =
1184
+ existing !== undefined && existingCollapsed
1185
+ ? refreshCollapsedThinking(reconciled ?? existing, family, retired)
1186
+ : reconciled;
1187
+ if (refreshed !== undefined && refreshed !== existing) {
1188
+ familyIdBySpecId.set(family.id, family.id);
1189
+ replacement.set(family.id, refreshed);
1190
+ }
1191
+ continue;
1192
+ }
1193
+
1194
+ for (const id of rawPresent) familyIdBySpecId.set(id, family.id);
1195
+ if (existing) familyIdBySpecId.set(family.id, family.id);
1196
+
1197
+ if (existingCollapsed) {
1198
+ // Mixed input: the collapsed entry (live truth) wins; stale raw
1199
+ // members are deduped away. Retired targets are re-pointed first.
1200
+ replacement.set(family.id, reconciled as TSpec);
1201
+ continue;
1202
+ }
1203
+
1204
+ const memberSpecs = rawPresent.map(id => byId.get(id) as TSpec);
1205
+ const presentSet = new Set(rawPresent);
1206
+ const routing: Partial<Record<Effort | "off", string>> = {};
1207
+ let hasRouting = false;
1208
+ let hasEffortRoute = false;
1209
+ let usedAbsentEffortRoute = false;
1210
+ for (const effortKey in family.routing) {
1211
+ const target = family.routing[effortKey as Effort | "off"];
1212
+ const effort = effortKey as Effort | "off";
1213
+ const targetPresent = target !== undefined && presentSet.has(target);
1214
+ const preserveAbsentEffort =
1215
+ target !== undefined && effort !== "off" && family.preserveAbsentEffortRoutes === true;
1216
+ if (target !== undefined && (targetPresent || preserveAbsentEffort) && !retired?.has(target)) {
1217
+ routing[effort] = target;
1218
+ hasRouting = true;
1219
+ if (effortKey !== "off") hasEffortRoute = true;
1220
+ if (!targetPresent && effort !== "off") usedAbsentEffortRoute = true;
1221
+ }
1222
+ }
1223
+
1224
+ // A family that routes efforts to a live thinking backing id reasons
1225
+ // even when upstream metadata forgot to mark the members.
1226
+ const reasoning = memberSpecs.some(spec => spec.reasoning) || hasEffortRoute;
1227
+ const thinking: ThinkingConfig = { ...family.thinking };
1228
+ if (hasRouting) thinking.effortRouting = routing;
1229
+ if (family.suppressWhenOff) thinking.suppressWhenOff = true;
1230
+
1231
+ const input: ("text" | "image")[] = [];
1232
+ if (memberSpecs.some(spec => spec.input.includes("text"))) input.push("text");
1233
+ if (memberSpecs.some(spec => spec.input.includes("image"))) input.push("image");
1234
+
1235
+ const collapsed: TSpec = {
1236
+ ...(memberSpecs[0] as TSpec),
1237
+ id: family.id,
1238
+ name: family.name,
1239
+ reasoning,
1240
+ input,
1241
+ contextWindow: maxOrNull(memberSpecs.map(spec => spec.contextWindow)),
1242
+ maxTokens: maxOrNull(memberSpecs.map(spec => spec.maxTokens)),
1243
+ };
1244
+ // The default wire id is the highest-priority live member; omit when it
1245
+ // equals the logical id (bare/thinking pairs) — `resolveWireModelId`
1246
+ // falls back. Retired members never become the default.
1247
+ const defaultWireId = rawPresent.find(id => !retired?.has(id)) ?? rawPresent[0];
1248
+ if (defaultWireId === family.id) {
1249
+ if (usedAbsentEffortRoute) {
1250
+ collapsed.requestModelId = defaultWireId as string;
1251
+ } else {
1252
+ delete collapsed.requestModelId;
1253
+ }
1254
+ } else {
1255
+ collapsed.requestModelId = defaultWireId as string;
1256
+ }
1257
+ if (reasoning) {
1258
+ collapsed.thinking = thinking;
1259
+ } else {
1260
+ delete collapsed.thinking;
1261
+ }
1262
+ replacement.set(family.id, collapsed);
1263
+ }
1264
+
1265
+ // Refresh stale alias-keyed snapshots in place (recycled bare ids). Runs even
1266
+ // when the canonical family.id row is also present, since the exact-id merge
1267
+ // keeps the stale alias row alongside the discovered canonical one.
1268
+ for (const family of table.families) {
1269
+ if (family.extraAliases === undefined) continue;
1270
+ const retired =
1271
+ family.retiredMembers !== undefined && family.retiredMembers.length > 0
1272
+ ? new Set(family.retiredMembers)
1273
+ : undefined;
1274
+ for (const alias of family.extraAliases) {
1275
+ if (alias === family.id || familyIdBySpecId.has(alias)) continue;
1276
+ const aliasSpec = byId.get(alias);
1277
+ if (aliasSpec === undefined) continue;
1278
+ const refreshed = refreshCollapsedThinking(aliasSpec, family, retired);
1279
+ if (refreshed !== aliasSpec) {
1280
+ familyIdBySpecId.set(alias, alias);
1281
+ replacement.set(alias, refreshed);
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ if (replacement.size === 0) return [...specs];
1287
+
1288
+ const emitted = new Set<string>();
1289
+ const out: TSpec[] = [];
1290
+ for (const spec of specs) {
1291
+ const familyId = familyIdBySpecId.get(spec.id);
1292
+ if (familyId === undefined) {
1293
+ out.push(spec);
1294
+ continue;
1295
+ }
1296
+ if (emitted.has(familyId)) continue;
1297
+ emitted.add(familyId);
1298
+ out.push(replacement.get(familyId) as TSpec);
1299
+ }
1300
+ return out;
1301
+ }
1302
+
1303
+ /**
1304
+ * Re-key model-to-model configuration after collapse removes a referenced
1305
+ * member id. Qualified targets keep their provider; bare targets remain bare.
1306
+ */
1307
+ function retargetCollapsedModelReferences<TSpec extends VariantSpecLike>(specs: TSpec[]): void {
1308
+ const liveIdsByProvider = new Map<string, Set<string>>();
1309
+ for (const spec of specs) {
1310
+ const provider = spec.provider.toLowerCase();
1311
+ let liveIds = liveIdsByProvider.get(provider);
1312
+ if (!liveIds) {
1313
+ liveIds = new Set<string>();
1314
+ liveIdsByProvider.set(provider, liveIds);
1315
+ }
1316
+ liveIds.add(spec.id.toLowerCase());
1317
+ }
1318
+
1319
+ for (let index = 0; index < specs.length; index++) {
1320
+ const spec = specs[index];
1321
+ if (!spec) continue;
1322
+ const contextPromotionTarget = resolveCollapsedModelReference(
1323
+ spec.contextPromotionTarget,
1324
+ spec.provider,
1325
+ liveIdsByProvider,
1326
+ );
1327
+ const compactionModel = resolveCollapsedModelReference(spec.compactionModel, spec.provider, liveIdsByProvider);
1328
+ if (contextPromotionTarget === spec.contextPromotionTarget && compactionModel === spec.compactionModel) continue;
1329
+ specs[index] = { ...spec, contextPromotionTarget, compactionModel };
1330
+ }
1331
+ }
1332
+
1333
+ function resolveCollapsedModelReference(
1334
+ target: string | undefined,
1335
+ currentProvider: Provider,
1336
+ liveIdsByProvider: ReadonlyMap<string, ReadonlySet<string>>,
1337
+ ): string | undefined {
1338
+ if (target === undefined) return undefined;
1339
+ const separator = target.indexOf("/");
1340
+ const provider = separator >= 0 ? target.slice(0, separator) : currentProvider;
1341
+ const providerId = provider.toLowerCase();
1342
+ const modelId = separator >= 0 ? target.slice(separator + 1) : target;
1343
+ const normalizedModelId = modelId.trim().toLowerCase();
1344
+ const liveIds = liveIdsByProvider.get(providerId);
1345
+ if (liveIds?.has(normalizedModelId)) return target;
1346
+ const alias = resolveRegisteredVariantAlias(provider, normalizedModelId);
1347
+ if (alias === undefined || !liveIds?.has(alias.toLowerCase())) return target;
1348
+ return separator >= 0 ? `${provider}/${alias}` : alias;
1349
+ }
1350
+
1351
+ /**
1352
+ * Collapse a full mixed-provider list: per provider, the hand table, Cursor's
1353
+ * conservative live effort-sibling rule, and the automatic `X`/`X-thinking`
1354
+ * pair rule. Used by the catalog generator; the runtime equivalent lives at
1355
+ * the model-manager merge point. Output is regrouped by provider — callers
1356
+ * re-sort.
1357
+ */
1358
+ export function collapseEffortVariantsAcrossProviders<TSpec extends VariantSpecLike>(specs: readonly TSpec[]): TSpec[] {
1359
+ const byProvider = new Map<string, TSpec[]>();
1360
+ for (const spec of specs) {
1361
+ const slice = byProvider.get(spec.provider);
1362
+ if (slice) {
1363
+ slice.push(spec);
1364
+ } else {
1365
+ byProvider.set(spec.provider, [spec]);
1366
+ }
1367
+ }
1368
+ const out: TSpec[] = [];
1369
+ for (const [provider, slice] of byProvider) {
1370
+ const table = VARIANT_COLLAPSE_TABLES[provider];
1371
+ let result = table ? collapseEffortVariants(slice, table) : slice;
1372
+ if (provider === "cursor") {
1373
+ const cursorDerived = deriveCursorEffortFamilies(result);
1374
+ if (cursorDerived.length > 0) {
1375
+ result = collapseEffortVariants(result, { families: cursorDerived });
1376
+ }
1377
+ }
1378
+ const derived = deriveThinkingPairFamilies(result, table);
1379
+ if (derived.length > 0) {
1380
+ result = collapseEffortVariants(result, { families: derived });
1381
+ }
1382
+ registerCollapsedVariantAliases(provider, result);
1383
+ out.push(...result);
1384
+ }
1385
+ retargetCollapsedModelReferences(out);
1386
+ return out;
1387
+ }
1388
+
1389
+ /**
1390
+ * Runtime entry point for already-built `Model` lists (the model-manager
1391
+ * merge point, coding-agent registry custom providers): collapses hand
1392
+ * tables plus derived pairs, then re-runs `buildModel` on freshly created
1393
+ * logical specs so thinking wire defaults stay resolved. Untouched entries
1394
+ * pass through by reference.
1395
+ */
1396
+ export function collapseBuiltModelVariants<TApi extends Api>(models: readonly Model<TApi>[]): Model<TApi>[] {
1397
+ const collapsed = collapseEffortVariantsAcrossProviders(models);
1398
+ const inputRefs = new Set<Model<TApi>>(models);
1399
+ return collapsed.map(model =>
1400
+ // Rebuild from a projected spec (sparse compatConfig) instead of resolved compat.
1401
+ inputRefs.has(model) ? model : buildModel({ ...model, compat: model.compatConfig } as unknown as ModelSpec<TApi>),
1402
+ );
1403
+ }
1404
+
1405
+ interface VariantAliasIndex {
1406
+ /** lowercased retired id → replacement model id. */
1407
+ forward: Map<string, string>;
1408
+ /** replacement model id → retired ids that resolve to it. */
1409
+ reverse: Map<string, string[]>;
1410
+ /** Collapsed logical ids declared by the table or observed at runtime. */
1411
+ familyIds: Set<string>;
1412
+ }
1413
+
1414
+ const dynamicAliasIndexes = new Map<string, VariantAliasIndex>();
1415
+ const VARIANT_ROUTING_KEYS: readonly (Effort | "off")[] = ["off", ...THINKING_EFFORTS];
1416
+
1417
+ const kAliasIndex = Symbol("variant-collapse.aliasIndex");
1418
+
1419
+ interface TableWithAliasIndex extends VariantCollapseTable {
1420
+ [kAliasIndex]?: VariantAliasIndex;
1421
+ }
1422
+
1423
+ function createAliasIndex(): VariantAliasIndex {
1424
+ return {
1425
+ forward: new Map<string, string>(),
1426
+ reverse: new Map<string, string[]>(),
1427
+ familyIds: new Set<string>(),
1428
+ };
1429
+ }
1430
+
1431
+ function addVariantAlias(index: VariantAliasIndex, from: string, to: string): boolean {
1432
+ if (from === to || index.forward.has(from.toLowerCase())) return false;
1433
+ index.forward.set(from.toLowerCase(), to);
1434
+ const sources = index.reverse.get(to);
1435
+ if (sources) {
1436
+ sources.push(from);
1437
+ } else {
1438
+ index.reverse.set(to, [from]);
1439
+ }
1440
+ return true;
1441
+ }
1442
+
1443
+ /**
1444
+ * Persist aliases embedded in collapsed routing so generated catalog rows and
1445
+ * newly discovered families expose the same selector migrations as hand tables.
1446
+ */
1447
+ function registerCollapsedVariantAliases(provider: Provider, specs: readonly VariantSpecLike[]): void {
1448
+ const providerId = provider.toLowerCase();
1449
+ let index = dynamicAliasIndexes.get(providerId);
1450
+ for (const spec of specs) {
1451
+ const routing = spec.thinking?.effortRouting;
1452
+ if (!routing) continue;
1453
+ let registered = false;
1454
+ for (const effort of VARIANT_ROUTING_KEYS) {
1455
+ const source = routing[effort];
1456
+ if (!source || source === spec.id) continue;
1457
+ index ??= createAliasIndex();
1458
+ registered = addVariantAlias(index, source, spec.id) || registered;
1459
+ }
1460
+ if (spec.requestModelId && spec.requestModelId !== spec.id) {
1461
+ index ??= createAliasIndex();
1462
+ registered = addVariantAlias(index, spec.requestModelId, spec.id) || registered;
1463
+ }
1464
+ if (registered) index?.familyIds.add(spec.id);
1465
+ }
1466
+ if (index) dynamicAliasIndexes.set(providerId, index);
1467
+ }
1468
+
1469
+ function resolveRegisteredVariantAlias(provider: Provider, normalizedModelId: string): string | undefined {
1470
+ const providerId = provider.toLowerCase();
1471
+ const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[providerId];
1472
+ return (
1473
+ (table ? getAliasIndex(table).forward.get(normalizedModelId) : undefined) ??
1474
+ dynamicAliasIndexes.get(providerId)?.forward.get(normalizedModelId)
1475
+ );
1476
+ }
1477
+
1478
+ function getAliasIndex(table: VariantCollapseTable): VariantAliasIndex {
1479
+ const tagged = table as TableWithAliasIndex;
1480
+ const cached = tagged[kAliasIndex];
1481
+ if (cached) return cached;
1482
+ const index = createAliasIndex();
1483
+ for (const family of table.families) {
1484
+ index.familyIds.add(family.id);
1485
+ for (const member of family.members) addVariantAlias(index, member, family.id);
1486
+ for (const alias of family.extraAliases ?? []) addVariantAlias(index, alias, family.id);
1487
+ }
1488
+ tagged[kAliasIndex] = index;
1489
+ return index;
1490
+ }
1491
+
1492
+ /**
1493
+ * Resolve a retired effort-tier variant id (collapsed member, recycled id) to
1494
+ * its replacement model id for `provider` via hand-table or registered live
1495
+ * aliases. Returns `undefined` when the id is not a known alias; derived
1496
+ * `X-thinking` members also resolve through `stripThinkingVariantToken`.
1497
+ * Callers must try an exact model lookup first — a live model always wins over
1498
+ * an alias.
1499
+ */
1500
+ export function resolveVariantAlias(provider: Provider, modelId: string): string | undefined {
1501
+ return resolveRegisteredVariantAlias(provider, modelId.trim().toLowerCase());
1502
+ }
1503
+
1504
+ /** Bare-id alias hit: replacement id plus the providers declaring it. */
1505
+ export interface BareVariantAliasHit {
1506
+ id: string;
1507
+ /** Providers declaring the alias — candidates from these win ties. */
1508
+ providers: readonly Provider[];
1509
+ }
1510
+
1511
+ /**
1512
+ * Provider-agnostic alias lookup for bare-id selectors. Returns the declaring
1513
+ * providers so callers can prefer their models when the replacement id exists
1514
+ * on unrelated providers too (e.g. a retired Cursor tier id must not resolve
1515
+ * to `openai/gpt-5.4`).
1516
+ */
1517
+ export function resolveBareVariantAlias(modelId: string): BareVariantAliasHit | undefined {
1518
+ const normalized = modelId.trim().toLowerCase();
1519
+ const providerIds = new Set<string>();
1520
+ for (const provider in VARIANT_COLLAPSE_TABLES) providerIds.add(provider);
1521
+ for (const provider of dynamicAliasIndexes.keys()) providerIds.add(provider);
1522
+ for (const provider of providerIds) {
1523
+ const hit = resolveRegisteredVariantAlias(provider, normalized);
1524
+ if (hit === undefined) continue;
1525
+ const providers: Provider[] = [];
1526
+ for (const candidate of providerIds) {
1527
+ if (resolveRegisteredVariantAlias(candidate, normalized) === hit) {
1528
+ providers.push(candidate);
1529
+ }
1530
+ }
1531
+ return { id: hit, providers };
1532
+ }
1533
+ return undefined;
1534
+ }
1535
+
1536
+ /**
1537
+ * Reverse alias lookup: the retired ids that resolve to `modelId` for
1538
+ * `provider` via hand-table or registered live aliases. Used to re-key config
1539
+ * keyed by raw member ids (models.yml `modelOverrides`, suppressed selectors)
1540
+ * onto the collapsed model.
1541
+ */
1542
+ export function getVariantAliasSources(provider: Provider, modelId: string): readonly string[] {
1543
+ const providerId = provider.toLowerCase();
1544
+ const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[providerId];
1545
+ const staticSources = table ? getAliasIndex(table).reverse.get(modelId) : undefined;
1546
+ const dynamicSources = dynamicAliasIndexes.get(providerId)?.reverse.get(modelId);
1547
+ if (!staticSources) return dynamicSources ?? [];
1548
+ if (!dynamicSources) return staticSources;
1549
+ return [...new Set([...staticSources, ...dynamicSources])];
1550
+ }
1551
+
1552
+ function maxOrNull(values: ReadonlyArray<number | null>): number | null {
1553
+ const known = values.filter((v): v is number => v != null);
1554
+ return known.length ? Math.max(...known) : null;
1555
+ }