jeopi-catalog 16.2.13

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 (137) hide show
  1. package/CHANGELOG.md +523 -0
  2. package/README.md +43 -0
  3. package/dist/types/build.d.ts +3 -0
  4. package/dist/types/compat/anthropic.d.ts +11 -0
  5. package/dist/types/compat/apply.d.ts +7 -0
  6. package/dist/types/compat/devin.d.ts +9 -0
  7. package/dist/types/compat/openai.d.ts +28 -0
  8. package/dist/types/discovery/antigravity.d.ts +70 -0
  9. package/dist/types/discovery/codex.d.ts +38 -0
  10. package/dist/types/discovery/cursor-gen/agent_pb.d.ts +13022 -0
  11. package/dist/types/discovery/cursor.d.ts +23 -0
  12. package/dist/types/discovery/devin-gen/buf/validate/validate_pb.d.ts +1715 -0
  13. package/dist/types/discovery/devin-gen/exa/analytics_pb/analytics_pb.d.ts +693 -0
  14. package/dist/types/discovery/devin-gen/exa/api_server_pb/api_server_pb.d.ts +9158 -0
  15. package/dist/types/discovery/devin-gen/exa/auth_pb/auth_pb.d.ts +52 -0
  16. package/dist/types/discovery/devin-gen/exa/auto_cascade_common_pb/auto_cascade_common_pb.d.ts +276 -0
  17. package/dist/types/discovery/devin-gen/exa/bug_checker_pb/bug_checker_pb.d.ts +78 -0
  18. package/dist/types/discovery/devin-gen/exa/cascade_plugins_pb/cascade_plugins_pb.d.ts +999 -0
  19. package/dist/types/discovery/devin-gen/exa/chat_pb/chat_pb.d.ts +1704 -0
  20. package/dist/types/discovery/devin-gen/exa/code_edit/code_edit_pb/code_edit_pb.d.ts +656 -0
  21. package/dist/types/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb.d.ts +15014 -0
  22. package/dist/types/discovery/devin-gen/exa/context_module_pb/context_module_pb.d.ts +607 -0
  23. package/dist/types/discovery/devin-gen/exa/cortex_pb/cortex_pb.d.ts +11830 -0
  24. package/dist/types/discovery/devin-gen/exa/diff_action_pb/diff_action_pb.d.ts +248 -0
  25. package/dist/types/discovery/devin-gen/exa/index_pb/index_pb.d.ts +1747 -0
  26. package/dist/types/discovery/devin-gen/exa/knowledge_base_pb/knowledge_base_pb.d.ts +509 -0
  27. package/dist/types/discovery/devin-gen/exa/language_server_pb/language_server_pb.d.ts +9048 -0
  28. package/dist/types/discovery/devin-gen/exa/opensearch_clients_pb/opensearch_clients_pb.d.ts +1760 -0
  29. package/dist/types/discovery/devin-gen/exa/prompt_pb/prompt_pb.d.ts +286 -0
  30. package/dist/types/discovery/devin-gen/exa/reactive_component_pb/reactive_component_pb.d.ts +405 -0
  31. package/dist/types/discovery/devin-gen/exa/trust_pb/trust_pb.d.ts +582 -0
  32. package/dist/types/discovery/devin.d.ts +24 -0
  33. package/dist/types/discovery/gemini.d.ts +25 -0
  34. package/dist/types/discovery/gitlab-duo-workflow.d.ts +30 -0
  35. package/dist/types/discovery/index.d.ts +5 -0
  36. package/dist/types/discovery/openai-compatible.d.ts +74 -0
  37. package/dist/types/effort.d.ts +9 -0
  38. package/dist/types/fireworks-model-id.d.ts +23 -0
  39. package/dist/types/hosts.d.ts +137 -0
  40. package/dist/types/identity/bundled.d.ts +3 -0
  41. package/dist/types/identity/classify.d.ts +69 -0
  42. package/dist/types/identity/dialect.d.ts +3 -0
  43. package/dist/types/identity/family.d.ts +139 -0
  44. package/dist/types/identity/id.d.ts +12 -0
  45. package/dist/types/identity/index.d.ts +8 -0
  46. package/dist/types/identity/markers.d.ts +4 -0
  47. package/dist/types/identity/priority.d.ts +1 -0
  48. package/dist/types/identity/reference.d.ts +23 -0
  49. package/dist/types/index.d.ts +17 -0
  50. package/dist/types/model-cache.d.ts +17 -0
  51. package/dist/types/model-manager.d.ts +68 -0
  52. package/dist/types/model-thinking.d.ts +79 -0
  53. package/dist/types/models.d.ts +12 -0
  54. package/dist/types/provider-models/bundled-references.d.ts +11 -0
  55. package/dist/types/provider-models/descriptor-types.d.ts +74 -0
  56. package/dist/types/provider-models/descriptors.d.ts +427 -0
  57. package/dist/types/provider-models/google.d.ts +27 -0
  58. package/dist/types/provider-models/index.d.ts +6 -0
  59. package/dist/types/provider-models/ollama.d.ts +9 -0
  60. package/dist/types/provider-models/openai-compat.d.ts +450 -0
  61. package/dist/types/provider-models/special.d.ts +33 -0
  62. package/dist/types/types.d.ts +656 -0
  63. package/dist/types/utils.d.ts +20 -0
  64. package/dist/types/variant-collapse.d.ts +133 -0
  65. package/dist/types/wire/codex.d.ts +26 -0
  66. package/dist/types/wire/coreweave.d.ts +12 -0
  67. package/dist/types/wire/gemini-headers.d.ts +37 -0
  68. package/dist/types/wire/github-copilot.d.ts +45 -0
  69. package/package.json +101 -0
  70. package/src/build.ts +47 -0
  71. package/src/compat/anthropic.ts +93 -0
  72. package/src/compat/apply.ts +15 -0
  73. package/src/compat/devin.ts +12 -0
  74. package/src/compat/openai.ts +689 -0
  75. package/src/discovery/antigravity.ts +253 -0
  76. package/src/discovery/codex.ts +371 -0
  77. package/src/discovery/cursor-gen/agent_pb.ts +15274 -0
  78. package/src/discovery/cursor.ts +314 -0
  79. package/src/discovery/devin-gen/buf/validate/validate_pb.ts +1862 -0
  80. package/src/discovery/devin-gen/exa/analytics_pb/analytics_pb.ts +871 -0
  81. package/src/discovery/devin-gen/exa/api_server_pb/api_server_pb.ts +11083 -0
  82. package/src/discovery/devin-gen/exa/auth_pb/auth_pb.ts +71 -0
  83. package/src/discovery/devin-gen/exa/auto_cascade_common_pb/auto_cascade_common_pb.ts +348 -0
  84. package/src/discovery/devin-gen/exa/bug_checker_pb/bug_checker_pb.ts +103 -0
  85. package/src/discovery/devin-gen/exa/cascade_plugins_pb/cascade_plugins_pb.ts +1198 -0
  86. package/src/discovery/devin-gen/exa/chat_pb/chat_pb.ts +2063 -0
  87. package/src/discovery/devin-gen/exa/code_edit/code_edit_pb/code_edit_pb.ts +810 -0
  88. package/src/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb.ts +18354 -0
  89. package/src/discovery/devin-gen/exa/context_module_pb/context_module_pb.ts +732 -0
  90. package/src/discovery/devin-gen/exa/cortex_pb/cortex_pb.ts +14277 -0
  91. package/src/discovery/devin-gen/exa/diff_action_pb/diff_action_pb.ts +312 -0
  92. package/src/discovery/devin-gen/exa/index_pb/index_pb.ts +2106 -0
  93. package/src/discovery/devin-gen/exa/knowledge_base_pb/knowledge_base_pb.ts +623 -0
  94. package/src/discovery/devin-gen/exa/language_server_pb/language_server_pb.ts +10918 -0
  95. package/src/discovery/devin-gen/exa/opensearch_clients_pb/opensearch_clients_pb.ts +2125 -0
  96. package/src/discovery/devin-gen/exa/prompt_pb/prompt_pb.ts +361 -0
  97. package/src/discovery/devin-gen/exa/reactive_component_pb/reactive_component_pb.ts +450 -0
  98. package/src/discovery/devin-gen/exa/trust_pb/trust_pb.ts +712 -0
  99. package/src/discovery/devin.ts +151 -0
  100. package/src/discovery/gemini.ts +260 -0
  101. package/src/discovery/gitlab-duo-workflow.ts +855 -0
  102. package/src/discovery/index.ts +5 -0
  103. package/src/discovery/openai-compatible.ts +249 -0
  104. package/src/effort.ts +16 -0
  105. package/src/fireworks-model-id.ts +50 -0
  106. package/src/hosts.ts +129 -0
  107. package/src/identity/bundled.ts +28 -0
  108. package/src/identity/classify.ts +214 -0
  109. package/src/identity/dialect.ts +42 -0
  110. package/src/identity/family.ts +296 -0
  111. package/src/identity/id.ts +81 -0
  112. package/src/identity/index.ts +8 -0
  113. package/src/identity/markers.ts +49 -0
  114. package/src/identity/priority.ts +57 -0
  115. package/src/identity/reference.ts +148 -0
  116. package/src/index.ts +17 -0
  117. package/src/model-cache.ts +171 -0
  118. package/src/model-manager.ts +520 -0
  119. package/src/model-thinking.ts +760 -0
  120. package/src/models.json +88898 -0
  121. package/src/models.json.d.ts +9 -0
  122. package/src/models.ts +64 -0
  123. package/src/provider-models/bundled-references.ts +58 -0
  124. package/src/provider-models/descriptor-types.ts +79 -0
  125. package/src/provider-models/descriptors.ts +503 -0
  126. package/src/provider-models/google.ts +107 -0
  127. package/src/provider-models/index.ts +6 -0
  128. package/src/provider-models/ollama.ts +172 -0
  129. package/src/provider-models/openai-compat.ts +4283 -0
  130. package/src/provider-models/special.ts +159 -0
  131. package/src/types.ts +786 -0
  132. package/src/utils.ts +69 -0
  133. package/src/variant-collapse.ts +1065 -0
  134. package/src/wire/codex.ts +43 -0
  135. package/src/wire/coreweave.ts +51 -0
  136. package/src/wire/gemini-headers.ts +74 -0
  137. package/src/wire/github-copilot.ts +121 -0
@@ -0,0 +1,1065 @@
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 } 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 DevinTierRoutes = Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string>>;
115
+
116
+ function devinTierFamily(
117
+ id: string,
118
+ name: string,
119
+ routes: DevinTierRoutes,
120
+ efforts: readonly Effort[],
121
+ ): EffortVariantFamily {
122
+ const routing: Partial<Record<Effort | "off", string>> = {};
123
+ if (routes.off) routing.off = routes.off;
124
+ for (const effort of efforts) {
125
+ switch (effort) {
126
+ case Effort.Minimal:
127
+ if (routes.minimal) {
128
+ routing[effort] = routes.minimal;
129
+ } else if (routes.low) {
130
+ routing[effort] = routes.low;
131
+ }
132
+ break;
133
+ case Effort.Low:
134
+ if (routes.low) routing[effort] = routes.low;
135
+ break;
136
+ case Effort.Medium:
137
+ if (routes.medium) routing[effort] = routes.medium;
138
+ break;
139
+ case Effort.High:
140
+ if (routes.high) routing[effort] = routes.high;
141
+ break;
142
+ case Effort.XHigh:
143
+ if (routes.xhigh) routing[effort] = routes.xhigh;
144
+ break;
145
+ }
146
+ }
147
+ const members = [routes.off, routes.minimal, routes.low, routes.medium, routes.high, routes.xhigh].filter(
148
+ (member, index, items): member is string => typeof member === "string" && items.indexOf(member) === index,
149
+ );
150
+ return {
151
+ id,
152
+ name,
153
+ members,
154
+ routing,
155
+ thinking: {
156
+ mode: "effort",
157
+ efforts,
158
+ ...(routes.off ? undefined : { requiresEffort: true }),
159
+ },
160
+ };
161
+ }
162
+
163
+ const GEMINI_3_FLASH_FAMILY_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
164
+ const GEMINI_3_PRO_FAMILY_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High];
165
+
166
+ /**
167
+ * Antigravity Cloud Code Assist sends an explicit `thinkingBudget` per tier
168
+ * (verified against captured `daily-cloudcode-pa` requests). Flash uses round
169
+ * budgets; Pro offsets every budget by +1. Minimal mirrors Low (the Antigravity
170
+ * UI exposes Low/Medium/High only) so the effort stays selectable.
171
+ */
172
+ const GEMINI_3_FLASH_FAMILY_BUDGETS: Readonly<Partial<Record<Effort, number>>> = {
173
+ [Effort.Minimal]: 1000,
174
+ [Effort.Low]: 1000,
175
+ [Effort.Medium]: 4000,
176
+ [Effort.High]: 10000,
177
+ };
178
+ const GEMINI_3_PRO_FAMILY_BUDGETS: Readonly<Partial<Record<Effort, number>>> = {
179
+ [Effort.Low]: 1001,
180
+ [Effort.High]: 10001,
181
+ };
182
+
183
+ /**
184
+ * The two Cloud Code Assist providers share the same Antigravity discovery list
185
+ * but disagree on the thinking transport: `google-antigravity` (daily-cloudcode-pa)
186
+ * sends an explicit `thinkingBudget` (verified against captured requests), while
187
+ * `google-gemini-cli` (cloudcode-pa) follows the official Gemini CLI and uses
188
+ * `thinkingLevel`. The Gemini 3.x families therefore differ only in thinking
189
+ * transport (and, for Flash, the per-tier wire-id routing); everything else is
190
+ * shared verbatim.
191
+ */
192
+ function geminiFlashFamily(mode: "budget" | "google-level"): EffortVariantFamily {
193
+ const budget = mode === "budget";
194
+ return {
195
+ id: "gemini-3.5-flash",
196
+ name: "Gemini 3.5 Flash",
197
+ members: ["gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3-flash-agent"],
198
+ routing: budget
199
+ ? {
200
+ off: "gemini-3.5-flash-extra-low",
201
+ [Effort.Minimal]: "gemini-3.5-flash-extra-low",
202
+ [Effort.Low]: "gemini-3.5-flash-extra-low",
203
+ [Effort.Medium]: "gemini-3.5-flash-low",
204
+ [Effort.High]: "gemini-3-flash-agent",
205
+ }
206
+ : {
207
+ off: "gemini-3.5-flash-extra-low",
208
+ [Effort.Minimal]: "gemini-3-flash-agent",
209
+ [Effort.Low]: "gemini-3.5-flash-extra-low",
210
+ [Effort.Medium]: "gemini-3.5-flash-extra-low",
211
+ [Effort.High]: "gemini-3.5-flash-low",
212
+ },
213
+ thinking: budget
214
+ ? { mode: "budget", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS, effortBudgets: GEMINI_3_FLASH_FAMILY_BUDGETS }
215
+ : { mode: "google-level", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS },
216
+ suppressWhenOff: true,
217
+ // Retired bare id; the alias only fires when no live model holds it
218
+ // (exact match wins in every resolver).
219
+ extraAliases: ["gemini-3-flash"],
220
+ };
221
+ }
222
+
223
+ function geminiProFamily(mode: "budget" | "google-level"): EffortVariantFamily {
224
+ const budget = mode === "budget";
225
+ return {
226
+ id: "gemini-3.1-pro",
227
+ name: "Gemini 3.1 Pro",
228
+ // High routes to `gemini-pro-agent` — the upstream `gemini-3.1-pro-high`
229
+ // deployment returns INVALID_ARGUMENT on every streamGenerateContent
230
+ // request (both CCA endpoints) while discovery still lists it;
231
+ // `gemini-pro-agent` is the same model ("Gemini 3.1 Pro (High)", same
232
+ // thinking budget/caps) and accepts the identical request body.
233
+ // `gemini-3.1-pro-high` stays a member so the dead raw id is consumed.
234
+ members: ["gemini-3.1-pro-low", "gemini-pro-agent", "gemini-3.1-pro-high"],
235
+ retiredMembers: ["gemini-3.1-pro-high"],
236
+ routing: {
237
+ off: "gemini-3.1-pro-low",
238
+ [Effort.Low]: "gemini-3.1-pro-low",
239
+ [Effort.High]: "gemini-pro-agent",
240
+ },
241
+ thinking: budget
242
+ ? { mode: "budget", efforts: GEMINI_3_PRO_FAMILY_EFFORTS, effortBudgets: GEMINI_3_PRO_FAMILY_BUDGETS }
243
+ : { mode: "google-level", efforts: GEMINI_3_PRO_FAMILY_EFFORTS },
244
+ suppressWhenOff: true,
245
+ };
246
+ }
247
+
248
+ /** CCA families shared verbatim by both providers (transport-agnostic). */
249
+ const SHARED_CCA_FAMILIES: readonly EffortVariantFamily[] = [
250
+ {
251
+ // Legacy static family — covers stale snapshots and caches. Stale ids are
252
+ // unverified against the budget-mode CCA contract; keep them on level.
253
+ id: "gemini-3-pro",
254
+ name: "Gemini 3 Pro",
255
+ members: ["gemini-3-pro-low", "gemini-3-pro-high"],
256
+ routing: {
257
+ off: "gemini-3-pro-low",
258
+ [Effort.Low]: "gemini-3-pro-low",
259
+ [Effort.High]: "gemini-3-pro-high",
260
+ },
261
+ thinking: { mode: "google-level", efforts: GEMINI_3_PRO_FAMILY_EFFORTS },
262
+ suppressWhenOff: true,
263
+ },
264
+ {
265
+ // Rename-only collapse: every effort and off fall back to the wire id.
266
+ id: "gpt-oss-120b",
267
+ name: "GPT-OSS 120B",
268
+ members: ["gpt-oss-120b-medium"],
269
+ routing: {},
270
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
271
+ },
272
+ // Antigravity Cloud Code Assist exposes Claude 4.6 asymmetrically: only the
273
+ // bare `claude-sonnet-4-6` wire id (no `-thinking` twin) and only the
274
+ // `claude-opus-4-6-thinking` wire id (no bare twin). Per-effort thinking is
275
+ // carried in the request body via `thinkingBudget`, so both ids accept on/off
276
+ // requests. Listing both candidates in `members` (priority order) keeps the
277
+ // collapse correct if the backend mix ever rebalances; `retiredMembers`
278
+ // re-points stale collapsed snapshots (bundled catalog rows, cache rows
279
+ // written by prior generations) away from the dead wire id via
280
+ // `reconcileRetiredRouting`.
281
+ {
282
+ id: "claude-sonnet-4-6",
283
+ name: "Claude Sonnet 4.6",
284
+ members: ["claude-sonnet-4-6", "claude-sonnet-4-6-thinking"],
285
+ retiredMembers: ["claude-sonnet-4-6-thinking"],
286
+ routing: {},
287
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
288
+ },
289
+
290
+ {
291
+ id: "claude-opus-4-6",
292
+ name: "Claude Opus 4.6",
293
+ members: ["claude-opus-4-6-thinking", "claude-opus-4-6"],
294
+ retiredMembers: ["claude-opus-4-6"],
295
+ routing: {},
296
+ thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] },
297
+ },
298
+ thinkingPair("claude-sonnet-4-5", "Claude Sonnet 4.5"),
299
+ thinkingPair("claude-opus-4-5", "Claude Opus 4.5"),
300
+ thinkingPair("gemini-2.5-flash", "Gemini 2.5 Flash"),
301
+ ];
302
+
303
+ /** `google-antigravity` (daily-cloudcode-pa): Gemini 3.x on the budget transport. */
304
+ export const ANTIGRAVITY_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
305
+ families: [geminiFlashFamily("budget"), geminiProFamily("budget"), ...SHARED_CCA_FAMILIES],
306
+ };
307
+
308
+ /** `google-gemini-cli` (cloudcode-pa): Gemini 3.x on the level transport (official CLI parity). */
309
+ export const GEMINI_CLI_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
310
+ families: [geminiFlashFamily("google-level"), geminiProFamily("google-level"), ...SHARED_CCA_FAMILIES],
311
+ };
312
+ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
313
+ families: [
314
+ {
315
+ id: "claude-opus-4-7",
316
+ name: "Claude Opus 4.7",
317
+ members: [
318
+ "claude-opus-4-7-low",
319
+ "claude-opus-4-7-medium",
320
+ "claude-opus-4-7-high",
321
+ "claude-opus-4-7-xhigh",
322
+ "claude-opus-4-7-max",
323
+ ],
324
+ routing: {
325
+ [Effort.Minimal]: "claude-opus-4-7-low",
326
+ [Effort.Low]: "claude-opus-4-7-medium",
327
+ [Effort.Medium]: "claude-opus-4-7-high",
328
+ [Effort.High]: "claude-opus-4-7-xhigh",
329
+ [Effort.XHigh]: "claude-opus-4-7-max",
330
+ },
331
+ thinking: {
332
+ mode: "effort",
333
+ efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
334
+ requiresEffort: true,
335
+ },
336
+ },
337
+ {
338
+ id: "claude-opus-4-7-fast",
339
+ name: "Claude Opus 4.7 Fast",
340
+ members: [
341
+ "claude-opus-4-7-low-fast",
342
+ "claude-opus-4-7-medium-fast",
343
+ "claude-opus-4-7-high-fast",
344
+ "claude-opus-4-7-xhigh-fast",
345
+ "claude-opus-4-7-max-fast",
346
+ ],
347
+ routing: {
348
+ [Effort.Minimal]: "claude-opus-4-7-low-fast",
349
+ [Effort.Low]: "claude-opus-4-7-medium-fast",
350
+ [Effort.Medium]: "claude-opus-4-7-high-fast",
351
+ [Effort.High]: "claude-opus-4-7-xhigh-fast",
352
+ [Effort.XHigh]: "claude-opus-4-7-max-fast",
353
+ },
354
+ thinking: {
355
+ mode: "effort",
356
+ efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
357
+ requiresEffort: true,
358
+ },
359
+ },
360
+ {
361
+ id: "claude-opus-4-8",
362
+ name: "Claude Opus 4.8",
363
+ members: [
364
+ "claude-opus-4-8-low",
365
+ "claude-opus-4-8-medium",
366
+ "claude-opus-4-8-high",
367
+ "claude-opus-4-8-xhigh",
368
+ "claude-opus-4-8-max",
369
+ ],
370
+ routing: {
371
+ [Effort.Minimal]: "claude-opus-4-8-low",
372
+ [Effort.Low]: "claude-opus-4-8-medium",
373
+ [Effort.Medium]: "claude-opus-4-8-high",
374
+ [Effort.High]: "claude-opus-4-8-xhigh",
375
+ [Effort.XHigh]: "claude-opus-4-8-max",
376
+ },
377
+ thinking: {
378
+ mode: "effort",
379
+ efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
380
+ requiresEffort: true,
381
+ },
382
+ },
383
+ {
384
+ id: "claude-opus-4-8-fast",
385
+ name: "Claude Opus 4.8 Fast",
386
+ members: [
387
+ "claude-opus-4-8-low-fast",
388
+ "claude-opus-4-8-medium-fast",
389
+ "claude-opus-4-8-high-fast",
390
+ "claude-opus-4-8-xhigh-fast",
391
+ "claude-opus-4-8-max-fast",
392
+ ],
393
+ routing: {
394
+ [Effort.Minimal]: "claude-opus-4-8-low-fast",
395
+ [Effort.Low]: "claude-opus-4-8-medium-fast",
396
+ [Effort.Medium]: "claude-opus-4-8-high-fast",
397
+ [Effort.High]: "claude-opus-4-8-xhigh-fast",
398
+ [Effort.XHigh]: "claude-opus-4-8-max-fast",
399
+ },
400
+ thinking: {
401
+ mode: "effort",
402
+ efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
403
+ requiresEffort: true,
404
+ },
405
+ },
406
+ devinTierFamily(
407
+ "gpt-5-2",
408
+ "GPT-5.2",
409
+ {
410
+ off: "MODEL_GPT_5_2_NONE",
411
+ low: "MODEL_GPT_5_2_LOW",
412
+ medium: "MODEL_GPT_5_2_MEDIUM",
413
+ high: "MODEL_GPT_5_2_HIGH",
414
+ xhigh: "MODEL_GPT_5_2_XHIGH",
415
+ },
416
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
417
+ ),
418
+ devinTierFamily(
419
+ "gpt-5-3-codex",
420
+ "GPT-5.3 Codex",
421
+ {
422
+ low: "gpt-5-3-codex-low",
423
+ medium: "gpt-5-3-codex-medium",
424
+ high: "gpt-5-3-codex-high",
425
+ xhigh: "gpt-5-3-codex-xhigh",
426
+ },
427
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
428
+ ),
429
+ devinTierFamily(
430
+ "gpt-5-3-codex-fast",
431
+ "GPT-5.3 Codex Fast",
432
+ {
433
+ low: "gpt-5-3-codex-low-priority",
434
+ medium: "gpt-5-3-codex-medium-priority",
435
+ high: "gpt-5-3-codex-high-priority",
436
+ xhigh: "gpt-5-3-codex-xhigh-priority",
437
+ },
438
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
439
+ ),
440
+ devinTierFamily(
441
+ "gpt-5-4",
442
+ "GPT-5.4",
443
+ {
444
+ off: "gpt-5-4-none",
445
+ low: "gpt-5-4-low",
446
+ medium: "gpt-5-4-medium",
447
+ high: "gpt-5-4-high",
448
+ xhigh: "gpt-5-4-xhigh",
449
+ },
450
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
451
+ ),
452
+ devinTierFamily(
453
+ "gpt-5-4-fast",
454
+ "GPT-5.4 Fast",
455
+ {
456
+ off: "gpt-5-4-none-priority",
457
+ low: "gpt-5-4-low-priority",
458
+ medium: "gpt-5-4-medium-priority",
459
+ high: "gpt-5-4-high-priority",
460
+ xhigh: "gpt-5-4-xhigh-priority",
461
+ },
462
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
463
+ ),
464
+ devinTierFamily(
465
+ "gpt-5-4-mini",
466
+ "GPT-5.4 Mini",
467
+ {
468
+ low: "gpt-5-4-mini-low",
469
+ medium: "gpt-5-4-mini-medium",
470
+ high: "gpt-5-4-mini-high",
471
+ xhigh: "gpt-5-4-mini-xhigh",
472
+ },
473
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
474
+ ),
475
+ devinTierFamily(
476
+ "gpt-5-5",
477
+ "GPT-5.5",
478
+ {
479
+ off: "gpt-5-5-none",
480
+ low: "gpt-5-5-low",
481
+ medium: "gpt-5-5-medium",
482
+ high: "gpt-5-5-high",
483
+ xhigh: "gpt-5-5-xhigh",
484
+ },
485
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
486
+ ),
487
+ devinTierFamily(
488
+ "gpt-5-5-fast",
489
+ "GPT-5.5 Fast",
490
+ {
491
+ off: "gpt-5-5-none-priority",
492
+ low: "gpt-5-5-low-priority",
493
+ medium: "gpt-5-5-medium-priority",
494
+ high: "gpt-5-5-high-priority",
495
+ xhigh: "gpt-5-5-xhigh-priority",
496
+ },
497
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
498
+ ),
499
+ devinTierFamily(
500
+ "gemini-3-1-pro",
501
+ "Gemini 3.1 Pro",
502
+ {
503
+ low: "gemini-3-1-pro-low",
504
+ high: "gemini-3-1-pro-high",
505
+ },
506
+ [Effort.Low, Effort.High],
507
+ ),
508
+ devinTierFamily(
509
+ "gemini-3-5-flash",
510
+ "Gemini 3.5 Flash",
511
+ {
512
+ minimal: "gemini-3-5-flash-minimal",
513
+ low: "gemini-3-5-flash-low",
514
+ medium: "gemini-3-5-flash-medium",
515
+ high: "gemini-3-5-flash-high",
516
+ },
517
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
518
+ ),
519
+ devinTierFamily(
520
+ "gemini-3-flash",
521
+ "Gemini 3 Flash",
522
+ {
523
+ minimal: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
524
+ low: "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
525
+ medium: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
526
+ high: "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
527
+ },
528
+ [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
529
+ ),
530
+ ],
531
+ };
532
+
533
+ /** Provider id → hand collapse table. The CCA providers diverge on thinking transport. */
534
+ export const VARIANT_COLLAPSE_TABLES: Readonly<Record<string, VariantCollapseTable>> = {
535
+ "google-antigravity": ANTIGRAVITY_VARIANT_COLLAPSE_TABLE,
536
+ "google-gemini-cli": GEMINI_CLI_VARIANT_COLLAPSE_TABLE,
537
+ devin: DEVIN_VARIANT_COLLAPSE_TABLE,
538
+ };
539
+
540
+ /**
541
+ * The global automatic rule: derive an `X` + `X-thinking` family for every
542
+ * pair where both ids are live in `specs` (trailing or infix token). Gates:
543
+ * - both members share the same `api`,
544
+ * - known pricing must match — all-zero cost rows count as unknown
545
+ * (aggregators routinely ship them), but twins that BOTH carry real,
546
+ * differing prices are distinct SKUs and never merge,
547
+ * - ids claimed by the provider's hand `table` are skipped (curation wins).
548
+ * The capability surface prefers the thinking member's metadata, then the
549
+ * bare member's, then the canonical deriver (aggregators often ship
550
+ * `reasoning: false` and no thinking config on the twin), then a budget
551
+ * default. `off` routes to the bare id; every supported effort routes to the
552
+ * thinking id.
553
+ */
554
+ export function deriveThinkingPairFamilies<TSpec extends VariantSpecLike>(
555
+ specs: readonly TSpec[],
556
+ table?: VariantCollapseTable,
557
+ ): EffortVariantFamily[] {
558
+ const byId = new Map<string, TSpec>();
559
+ for (const spec of specs) {
560
+ if (!byId.has(spec.id)) byId.set(spec.id, spec);
561
+ }
562
+ const claimed = table ? getAliasIndex(table) : undefined;
563
+ const families: EffortVariantFamily[] = [];
564
+ for (const spec of specs) {
565
+ const baseId = stripThinkingVariantToken(spec.id);
566
+ if (baseId === undefined || baseId === spec.id) continue;
567
+ const base = byId.get(baseId);
568
+ if (!base) continue;
569
+ if (claimed) {
570
+ const forward = claimed.forward;
571
+ if (
572
+ forward.has(spec.id.toLowerCase()) ||
573
+ forward.has(baseId.toLowerCase()) ||
574
+ claimed.familyIds.has(spec.id) ||
575
+ claimed.familyIds.has(baseId)
576
+ ) {
577
+ continue;
578
+ }
579
+ }
580
+ if (spec.api !== base.api) continue;
581
+ const specPriced = spec.cost.input !== 0 || spec.cost.output !== 0;
582
+ const basePriced = base.cost.input !== 0 || base.cost.output !== 0;
583
+ if (
584
+ specPriced &&
585
+ basePriced &&
586
+ (spec.cost.input !== base.cost.input ||
587
+ spec.cost.output !== base.cost.output ||
588
+ spec.cost.cacheRead !== base.cost.cacheRead ||
589
+ spec.cost.cacheWrite !== base.cost.cacheWrite)
590
+ ) {
591
+ continue;
592
+ }
593
+ const surface = derivePairThinkingSurface(spec, base);
594
+ const routing: Partial<Record<Effort | "off", string>> = { off: base.id };
595
+ for (const effort of surface.efforts) {
596
+ routing[effort] = spec.id;
597
+ }
598
+ families.push({
599
+ id: base.id,
600
+ name: base.name,
601
+ members: [base.id, spec.id],
602
+ routing,
603
+ thinking: surface,
604
+ });
605
+ }
606
+ return families;
607
+ }
608
+
609
+ const DEFAULT_PAIR_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
610
+
611
+ /**
612
+ * Surface fallback chain: thinking member → bare member → canonical deriver →
613
+ * budget default. `requiresEffort` is dropped from every source: the COLLAPSED
614
+ * pair can disable thinking (off routes to the bare backing id), even though
615
+ * the thinking member alone cannot.
616
+ */
617
+ function derivePairThinkingSurface(
618
+ thinkingSpec: VariantSpecLike,
619
+ baseSpec: VariantSpecLike,
620
+ ): Omit<ThinkingConfig, "effortRouting" | "suppressWhenOff" | "requiresEffort"> {
621
+ const baked = thinkingSpec.thinking ?? baseSpec.thinking;
622
+ if (baked && baked.efforts.length > 0) {
623
+ const { effortRouting: _routing, suppressWhenOff: _suppress, requiresEffort: _required, ...surface } = baked;
624
+ return surface;
625
+ }
626
+ const derived = resolveModelThinking(
627
+ { ...(thinkingSpec as unknown as ModelSpec<Api>), reasoning: true, thinking: undefined },
628
+ buildCompat(thinkingSpec as unknown as ModelSpec<Api>),
629
+ );
630
+ if (derived && derived.efforts.length > 0) {
631
+ const { effortRouting: _dRouting, suppressWhenOff: _dSuppress, requiresEffort: _dRequired, ...surface } = derived;
632
+ return surface;
633
+ }
634
+ return { mode: "budget", efforts: DEFAULT_PAIR_EFFORTS };
635
+ }
636
+
637
+ /**
638
+ * True when `spec` is the output of collapsing rather than a raw upstream
639
+ * member. `thinking.effortRouting` is written only by collapsing; the
640
+ * `requestModelId` arm is scoped to the provider's hand-table family ids so
641
+ * unrelated carriers (GitHub Copilot `-1m` context variants) never match.
642
+ */
643
+ export function isVariantCollapsedSpec(spec: VariantSpecLike): boolean {
644
+ if (spec.thinking?.effortRouting !== undefined) {
645
+ return true;
646
+ }
647
+ if (spec.requestModelId === undefined) {
648
+ return false;
649
+ }
650
+ const table = VARIANT_COLLAPSE_TABLES[spec.provider];
651
+ return table !== undefined && getAliasIndex(table).familyIds.has(spec.id);
652
+ }
653
+
654
+ /**
655
+ * Re-point a stale collapsed spec whose `requestModelId` or routing still
656
+ * targets a retired wire id. Collapsed snapshots (bundled catalog, cache
657
+ * rows, previous-generation fallbacks) pass through collapsing untouched, so
658
+ * a hand-table routing fix would otherwise never reach them. Only retired
659
+ * targets are rewritten — presence-filtered routing decisions from live
660
+ * discovery stay authoritative for everything else. Per retired entry the
661
+ * table's route for that effort wins, then the off/first-live-member wire id,
662
+ * then the route is dropped (falls back to `requestModelId ?? id`). Returns
663
+ * `spec` by reference when nothing targets a retired id.
664
+ */
665
+ function reconcileRetiredRouting<TSpec extends VariantSpecLike>(
666
+ spec: TSpec,
667
+ family: EffortVariantFamily,
668
+ retired: ReadonlySet<string>,
669
+ ): TSpec {
670
+ const routing = spec.thinking?.effortRouting;
671
+ const requestRetired = spec.requestModelId !== undefined && retired.has(spec.requestModelId);
672
+ let routingRetired = false;
673
+ if (routing !== undefined) {
674
+ for (const key in routing) {
675
+ const target = routing[key as Effort | "off"];
676
+ if (target !== undefined && retired.has(target)) {
677
+ routingRetired = true;
678
+ break;
679
+ }
680
+ }
681
+ }
682
+ if (!requestRetired && !routingRetired) return spec;
683
+
684
+ const offTarget = family.routing.off;
685
+ const fallbackWireId =
686
+ offTarget !== undefined && !retired.has(offTarget) ? offTarget : family.members.find(id => !retired.has(id));
687
+ const next: TSpec = { ...spec };
688
+ if (routingRetired && routing !== undefined) {
689
+ const nextRouting: Partial<Record<Effort | "off", string>> = {};
690
+ for (const key in routing) {
691
+ const effortKey = key as Effort | "off";
692
+ const target = routing[effortKey];
693
+ if (target === undefined) continue;
694
+ if (!retired.has(target)) {
695
+ nextRouting[effortKey] = target;
696
+ continue;
697
+ }
698
+ const tableTarget = family.routing[effortKey];
699
+ if (tableTarget !== undefined && !retired.has(tableTarget)) {
700
+ nextRouting[effortKey] = tableTarget;
701
+ } else if (fallbackWireId !== undefined) {
702
+ nextRouting[effortKey] = fallbackWireId;
703
+ }
704
+ }
705
+ next.thinking = { ...(spec.thinking as ThinkingConfig), effortRouting: nextRouting };
706
+ }
707
+ if (requestRetired) {
708
+ if (fallbackWireId !== undefined && fallbackWireId !== spec.id) {
709
+ next.requestModelId = fallbackWireId;
710
+ } else {
711
+ delete next.requestModelId;
712
+ }
713
+ }
714
+ return next;
715
+ }
716
+
717
+ /**
718
+ * Refresh a collapsed snapshot's thinking surface in place. Bundled catalog and
719
+ * prev-generation snapshots freeze a family's transport, budgets, and routing;
720
+ * discovery emits the canonical id but the exact-id merge never overwrites a
721
+ * stale `family.id` row (e.g. `gemini-3.1-pro`) nor a recycled `extraAliases`
722
+ * row (e.g. `gemini-3-flash`). This re-applies the hand-table family's thinking,
723
+ * routing, and default wire id while keeping the spec id (load-bearing for exact
724
+ * selectors and bundled lookups). Returns `spec` by reference when unchanged.
725
+ */
726
+ function refreshCollapsedThinking<TSpec extends VariantSpecLike>(
727
+ spec: TSpec,
728
+ family: EffortVariantFamily,
729
+ retired: ReadonlySet<string> | undefined,
730
+ ): TSpec {
731
+ // Scope snapshot self-heal to families carrying a curated per-effort budget
732
+ // contract (Antigravity gemini-3.x). Their routing targets are all verified
733
+ // live, so rebuilding routing here is safe; families without `effortBudgets`
734
+ // (derived `X`/`X-thinking` pairs, claude pairs) keep their presence-filtered
735
+ // snapshot routing untouched.
736
+ if (!spec.reasoning || family.thinking.effortBudgets === undefined) return spec;
737
+ const routing: Partial<Record<Effort | "off", string>> = {};
738
+ let hasRouting = false;
739
+ for (const effortKey in family.routing) {
740
+ const target = family.routing[effortKey as Effort | "off"];
741
+ if (target !== undefined && !retired?.has(target)) {
742
+ routing[effortKey as Effort | "off"] = target;
743
+ hasRouting = true;
744
+ }
745
+ }
746
+ const thinking: ThinkingConfig = { ...family.thinking };
747
+ if (hasRouting) thinking.effortRouting = routing;
748
+ if (family.suppressWhenOff) thinking.suppressWhenOff = true;
749
+ const offTarget = family.routing.off;
750
+ const requestModelId =
751
+ offTarget !== undefined && !retired?.has(offTarget) && offTarget !== spec.id ? offTarget : spec.requestModelId;
752
+ if (Bun.deepEquals(thinking, spec.thinking) && requestModelId === spec.requestModelId) {
753
+ return spec;
754
+ }
755
+ return { ...spec, thinking, ...(requestModelId !== undefined ? { requestModelId } : {}) };
756
+ }
757
+
758
+ /**
759
+ * Collapse every family in `table` found in `specs`. Non-member specs pass
760
+ * through verbatim (by reference), order preserved; the collapsed spec
761
+ * replaces the first occurrence of its family.
762
+ */
763
+ export function collapseEffortVariants<TSpec extends VariantSpecLike>(
764
+ specs: readonly TSpec[],
765
+ table: VariantCollapseTable,
766
+ ): TSpec[] {
767
+ const byId = new Map<string, TSpec>();
768
+ for (const spec of specs) {
769
+ if (!byId.has(spec.id)) byId.set(spec.id, spec);
770
+ }
771
+
772
+ /** family id → spec to emit at the family's first occurrence. */
773
+ const replacement = new Map<string, TSpec>();
774
+ /** spec ids that belong to a touched family (members + logical id). */
775
+ const familyIdBySpecId = new Map<string, string>();
776
+
777
+ for (const family of table.families) {
778
+ const retired =
779
+ family.retiredMembers !== undefined && family.retiredMembers.length > 0
780
+ ? new Set(family.retiredMembers)
781
+ : undefined;
782
+ const existing = byId.get(family.id);
783
+ const existingCollapsed =
784
+ existing !== undefined &&
785
+ (existing.requestModelId !== undefined || existing.thinking?.effortRouting !== undefined);
786
+ const reconciled =
787
+ existing !== undefined && existingCollapsed && retired !== undefined
788
+ ? reconcileRetiredRouting(existing, family, retired)
789
+ : existing;
790
+ const rawPresent = family.members.filter(id => byId.has(id) && !(id === family.id && existingCollapsed));
791
+ if (rawPresent.length === 0) {
792
+ // Inert (no members) or already collapsed (pass-through). A stale
793
+ // family.id-keyed snapshot is refreshed in place from the current
794
+ // hand-table family (transport/budgets/routing); retired targets drop.
795
+ // Recycled extraAliases rows are healed in a later pass.
796
+ const refreshed =
797
+ existing !== undefined && existingCollapsed
798
+ ? refreshCollapsedThinking(reconciled ?? existing, family, retired)
799
+ : reconciled;
800
+ if (refreshed !== undefined && refreshed !== existing) {
801
+ familyIdBySpecId.set(family.id, family.id);
802
+ replacement.set(family.id, refreshed);
803
+ }
804
+ continue;
805
+ }
806
+
807
+ for (const id of rawPresent) familyIdBySpecId.set(id, family.id);
808
+ if (existing) familyIdBySpecId.set(family.id, family.id);
809
+
810
+ if (existingCollapsed) {
811
+ // Mixed input: the collapsed entry (live truth) wins; stale raw
812
+ // members are deduped away. Retired targets are re-pointed first.
813
+ replacement.set(family.id, reconciled as TSpec);
814
+ continue;
815
+ }
816
+
817
+ const memberSpecs = rawPresent.map(id => byId.get(id) as TSpec);
818
+ const presentSet = new Set(rawPresent);
819
+ const routing: Partial<Record<Effort | "off", string>> = {};
820
+ let hasRouting = false;
821
+ let hasEffortRoute = false;
822
+ let usedAbsentEffortRoute = false;
823
+ for (const effortKey in family.routing) {
824
+ const target = family.routing[effortKey as Effort | "off"];
825
+ const effort = effortKey as Effort | "off";
826
+ const targetPresent = target !== undefined && presentSet.has(target);
827
+ const preserveAbsentEffort =
828
+ target !== undefined && effort !== "off" && family.preserveAbsentEffortRoutes === true;
829
+ if (target !== undefined && (targetPresent || preserveAbsentEffort) && !retired?.has(target)) {
830
+ routing[effort] = target;
831
+ hasRouting = true;
832
+ if (effortKey !== "off") hasEffortRoute = true;
833
+ if (!targetPresent && effort !== "off") usedAbsentEffortRoute = true;
834
+ }
835
+ }
836
+
837
+ // A family that routes efforts to a live thinking backing id reasons
838
+ // even when upstream metadata forgot to mark the members.
839
+ const reasoning = memberSpecs.some(spec => spec.reasoning) || hasEffortRoute;
840
+ const thinking: ThinkingConfig = { ...family.thinking };
841
+ if (hasRouting) thinking.effortRouting = routing;
842
+ if (family.suppressWhenOff) thinking.suppressWhenOff = true;
843
+
844
+ const input: ("text" | "image")[] = [];
845
+ if (memberSpecs.some(spec => spec.input.includes("text"))) input.push("text");
846
+ if (memberSpecs.some(spec => spec.input.includes("image"))) input.push("image");
847
+
848
+ const collapsed: TSpec = {
849
+ ...(memberSpecs[0] as TSpec),
850
+ id: family.id,
851
+ name: family.name,
852
+ reasoning,
853
+ input,
854
+ contextWindow: maxOrNull(memberSpecs.map(spec => spec.contextWindow)),
855
+ maxTokens: maxOrNull(memberSpecs.map(spec => spec.maxTokens)),
856
+ };
857
+ // The default wire id is the highest-priority live member; omit when it
858
+ // equals the logical id (bare/thinking pairs) — `resolveWireModelId`
859
+ // falls back. Retired members never become the default.
860
+ const defaultWireId = rawPresent.find(id => !retired?.has(id)) ?? rawPresent[0];
861
+ if (defaultWireId === family.id) {
862
+ if (usedAbsentEffortRoute) {
863
+ collapsed.requestModelId = defaultWireId as string;
864
+ } else {
865
+ delete collapsed.requestModelId;
866
+ }
867
+ } else {
868
+ collapsed.requestModelId = defaultWireId as string;
869
+ }
870
+ if (reasoning) {
871
+ collapsed.thinking = thinking;
872
+ } else {
873
+ delete collapsed.thinking;
874
+ }
875
+ replacement.set(family.id, collapsed);
876
+ }
877
+
878
+ // Refresh stale alias-keyed snapshots in place (recycled bare ids). Runs even
879
+ // when the canonical family.id row is also present, since the exact-id merge
880
+ // keeps the stale alias row alongside the discovered canonical one.
881
+ for (const family of table.families) {
882
+ if (family.extraAliases === undefined) continue;
883
+ const retired =
884
+ family.retiredMembers !== undefined && family.retiredMembers.length > 0
885
+ ? new Set(family.retiredMembers)
886
+ : undefined;
887
+ for (const alias of family.extraAliases) {
888
+ if (alias === family.id || familyIdBySpecId.has(alias)) continue;
889
+ const aliasSpec = byId.get(alias);
890
+ if (aliasSpec === undefined) continue;
891
+ const refreshed = refreshCollapsedThinking(aliasSpec, family, retired);
892
+ if (refreshed !== aliasSpec) {
893
+ familyIdBySpecId.set(alias, alias);
894
+ replacement.set(alias, refreshed);
895
+ }
896
+ }
897
+ }
898
+
899
+ if (replacement.size === 0) return [...specs];
900
+
901
+ const emitted = new Set<string>();
902
+ const out: TSpec[] = [];
903
+ for (const spec of specs) {
904
+ const familyId = familyIdBySpecId.get(spec.id);
905
+ if (familyId === undefined) {
906
+ out.push(spec);
907
+ continue;
908
+ }
909
+ if (emitted.has(familyId)) continue;
910
+ emitted.add(familyId);
911
+ out.push(replacement.get(familyId) as TSpec);
912
+ }
913
+ return out;
914
+ }
915
+
916
+ /**
917
+ * Collapse a full mixed-provider list: per provider, the hand table (when
918
+ * registered) plus the automatic `X`/`X-thinking` pair rule. Used by the
919
+ * catalog generator; the runtime equivalent lives at the model-manager merge
920
+ * point. Output is regrouped by provider — callers re-sort.
921
+ */
922
+ export function collapseEffortVariantsAcrossProviders<TSpec extends VariantSpecLike>(specs: readonly TSpec[]): TSpec[] {
923
+ const byProvider = new Map<string, TSpec[]>();
924
+ for (const spec of specs) {
925
+ const slice = byProvider.get(spec.provider);
926
+ if (slice) {
927
+ slice.push(spec);
928
+ } else {
929
+ byProvider.set(spec.provider, [spec]);
930
+ }
931
+ }
932
+ const out: TSpec[] = [];
933
+ for (const [provider, slice] of byProvider) {
934
+ const table = VARIANT_COLLAPSE_TABLES[provider];
935
+ let result = table ? collapseEffortVariants(slice, table) : slice;
936
+ const derived = deriveThinkingPairFamilies(result, table);
937
+ if (derived.length > 0) {
938
+ result = collapseEffortVariants(result, { families: derived });
939
+ }
940
+ out.push(...result);
941
+ }
942
+ return out;
943
+ }
944
+
945
+ /**
946
+ * Runtime entry point for already-built `Model` lists (the model-manager
947
+ * merge point, coding-agent registry custom providers): collapses hand
948
+ * tables plus derived pairs, then re-runs `buildModel` on freshly created
949
+ * logical specs so thinking wire defaults stay resolved. Untouched entries
950
+ * pass through by reference.
951
+ */
952
+ export function collapseBuiltModelVariants<TApi extends Api>(models: readonly Model<TApi>[]): Model<TApi>[] {
953
+ const collapsed = collapseEffortVariantsAcrossProviders(models);
954
+ const inputRefs = new Set<Model<TApi>>(models);
955
+ return collapsed.map(model =>
956
+ // Rebuild from a projected spec (sparse compatConfig) instead of resolved compat.
957
+ inputRefs.has(model) ? model : buildModel({ ...model, compat: model.compatConfig } as unknown as ModelSpec<TApi>),
958
+ );
959
+ }
960
+
961
+ interface VariantAliasIndex {
962
+ /** lowercased retired id → replacement model id. */
963
+ forward: Map<string, string>;
964
+ /** replacement model id → retired ids that resolve to it. */
965
+ reverse: Map<string, readonly string[]>;
966
+ /** Collapsed logical ids declared by the table. */
967
+ familyIds: Set<string>;
968
+ }
969
+
970
+ const kAliasIndex = Symbol("variant-collapse.aliasIndex");
971
+
972
+ interface TableWithAliasIndex extends VariantCollapseTable {
973
+ [kAliasIndex]?: VariantAliasIndex;
974
+ }
975
+
976
+ function getAliasIndex(table: VariantCollapseTable): VariantAliasIndex {
977
+ const tagged = table as TableWithAliasIndex;
978
+ const cached = tagged[kAliasIndex];
979
+ if (cached) return cached;
980
+ const forward = new Map<string, string>();
981
+ const reverse = new Map<string, string[]>();
982
+ const add = (from: string, to: string) => {
983
+ if (from === to) return;
984
+ forward.set(from.toLowerCase(), to);
985
+ const sources = reverse.get(to);
986
+ if (sources) {
987
+ sources.push(from);
988
+ } else {
989
+ reverse.set(to, [from]);
990
+ }
991
+ };
992
+ const familyIds = new Set<string>();
993
+ for (const family of table.families) {
994
+ familyIds.add(family.id);
995
+ for (const member of family.members) add(member, family.id);
996
+ for (const alias of family.extraAliases ?? []) add(alias, family.id);
997
+ }
998
+ const index: VariantAliasIndex = { forward, reverse, familyIds };
999
+ tagged[kAliasIndex] = index;
1000
+ return index;
1001
+ }
1002
+
1003
+ /**
1004
+ * Resolve a retired effort-tier variant id (collapsed member, recycled id) to
1005
+ * its replacement model id for `provider` via the hand table. Returns
1006
+ * `undefined` when the id is not a known alias; derived `X-thinking` members
1007
+ * resolve through `stripThinkingVariantToken` instead. Callers must try an
1008
+ * exact model lookup first — a live model always wins over an alias.
1009
+ */
1010
+ export function resolveVariantAlias(provider: Provider, modelId: string): string | undefined {
1011
+ const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[provider.toLowerCase()];
1012
+ if (!table) return undefined;
1013
+ return getAliasIndex(table).forward.get(modelId.trim().toLowerCase());
1014
+ }
1015
+
1016
+ /** Bare-id alias hit: replacement id plus the providers declaring it. */
1017
+ export interface BareVariantAliasHit {
1018
+ id: string;
1019
+ /** Providers whose table declares the alias — candidates from these win ties. */
1020
+ providers: readonly Provider[];
1021
+ }
1022
+
1023
+ /**
1024
+ * Provider-agnostic hand-table alias lookup for bare-id selectors. Returns
1025
+ * the declaring providers so callers can prefer their models when the
1026
+ * replacement id exists on unrelated providers too (e.g. a retired Cursor
1027
+ * tier id must not resolve to `openai/gpt-5.4`).
1028
+ */
1029
+ export function resolveBareVariantAlias(modelId: string): BareVariantAliasHit | undefined {
1030
+ const normalized = modelId.trim().toLowerCase();
1031
+ for (const provider in VARIANT_COLLAPSE_TABLES) {
1032
+ const table = VARIANT_COLLAPSE_TABLES[provider] as VariantCollapseTable;
1033
+ const hit = getAliasIndex(table).forward.get(normalized);
1034
+ if (hit === undefined) continue;
1035
+ const providers: Provider[] = [];
1036
+ for (const candidate in VARIANT_COLLAPSE_TABLES) {
1037
+ // Match by resolved alias target, not table identity: the CCA providers
1038
+ // now hold distinct table objects that still share these aliases.
1039
+ if (
1040
+ getAliasIndex(VARIANT_COLLAPSE_TABLES[candidate] as VariantCollapseTable).forward.get(normalized) === hit
1041
+ ) {
1042
+ providers.push(candidate);
1043
+ }
1044
+ }
1045
+ return { id: hit, providers };
1046
+ }
1047
+ return undefined;
1048
+ }
1049
+
1050
+ /**
1051
+ * Reverse alias lookup: the retired ids that resolve to `modelId` for
1052
+ * `provider` via the hand table. Used to re-key config keyed by raw member
1053
+ * ids (models.yml `modelOverrides`, suppressed selectors) onto the collapsed
1054
+ * model. Empty for providers without a table.
1055
+ */
1056
+ export function getVariantAliasSources(provider: Provider, modelId: string): readonly string[] {
1057
+ const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[provider.toLowerCase()];
1058
+ if (!table) return [];
1059
+ return getAliasIndex(table).reverse.get(modelId) ?? [];
1060
+ }
1061
+
1062
+ function maxOrNull(values: ReadonlyArray<number | null>): number | null {
1063
+ const known = values.filter((v): v is number => v != null);
1064
+ return known.length ? Math.max(...known) : null;
1065
+ }