/** * Effort-tier variant collapsing. * * Some providers expose one logical model as several effort- or * thinking-suffixed upstream ids (Antigravity CCA: * `gemini-3.5-flash-extra-low`/`-low`, `claude-*`/`claude-*-thinking` pairs; * aggregators: `X`/`X-thinking` twins). Collapsing replaces the member specs * with one logical spec whose `thinking.effortRouting` records the per-effort * upstream wire id; request-time code resolves the outbound id via * `resolveWireModelId` and everything local (selection, caching, usage * attribution) keys on the logical `id`. * * Families come from two sources: * - Hand tables (`VARIANT_COLLAPSE_TABLES`) for providers whose routing needs * curation (Antigravity tier triplets, single-member renames, recycled ids). * - `deriveThinkingPairFamilies`: the global automatic rule — any live * `X` + `X-thinking` pair (trailing or infix token) collapses into `X`, * routing thinking-enabled requests to `X-thinking`. Gated on identical * pricing and same api: price-divergent twins are distinct SKUs and stay * separate so billing attribution never lies. * * Family invariants (hold for hand-written and derived tables): * - One axis per family. A second id axis (e.g. Cursor's `-fast` service * tier) becomes a sibling family, never a second routing dimension. * - The collapsed spec inherits non-tier fields from the first present * member; members must be cost-homogeneous. * * `collapseEffortVariants` is pure, deterministic, and idempotent: * `collapse(collapse(x))` equals `collapse(x)`, and mixed raw+collapsed input * (stale cache rows, previous-snapshot fallbacks) dedupes to the collapsed * entry. That makes it safe at every source — discovery, the catalog * generator, and the model-manager merge point. */ import { buildCompat, buildModel } from "./build"; import { Effort } from "./effort"; import { stripThinkingVariantToken } from "./identity/family"; import { resolveModelThinking } from "./model-thinking"; import type { Api, Model, ModelSpec, Provider, ThinkingConfig } from "./types"; /** * Structural bound for collapse inputs: both raw `ModelSpec`s and built * `Model`s qualify. (`Model.compat` is the resolved record, not the sparse * config, so the two are not mutually assignable — collapsing never touches * `compat`.) */ export type VariantSpecLike = Omit, "compat"> & { compat?: unknown }; /** One collapsed family: logical id + member wire ids + per-effort routing. */ export interface EffortVariantFamily { /** Collapsed logical id (may equal a member id — e.g. bare/thinking pairs). */ id: string; /** Final display name, no tier marker. */ name: string; /** * Member wire ids in priority order. The first member present in the input * becomes the collapsed spec's default wire id (`requestModelId`; omitted * when it equals the logical id). */ members: readonly string[]; /** * Wire ids upstream no longer serves (e.g. a deployment killed while * discovery still advertises it). Fresh collapsing never routes to them, * and stale collapsed snapshots (bundled catalog, cache rows, * previous-generation fallbacks) get routing/`requestModelId` entries that * target them re-pointed through `routing`. Keep retired ids in `members` * so the raw upstream spec is still consumed and aliased. */ retiredMembers?: readonly string[]; /** * Per-effort upstream wire id; `"off"` applies when thinking is disabled. * Entries whose target member is absent from the input are dropped — those * efforts fall back to `requestModelId ?? id`. */ routing: Readonly>>; /** Explicit capability surface for the collapsed spec — no inference. */ thinking: Readonly>; /** Thinking-off requests must explicitly suppress thinking on the wire. */ suppressWhenOff?: boolean; /** * Preserve non-off effort routes even when discovery omits the backing member. * Used for Cloud Code Assist `X`/`X-thinking` pairs where upstream accepts * the `-thinking` wire id but the model-list endpoint may advertise only the * bare id. */ preserveAbsentEffortRoutes?: boolean; /** Retired/recycled selector ids that alias to this family without being members. */ extraAliases?: readonly string[]; } export interface VariantCollapseTable { families: readonly EffortVariantFamily[]; } /** `X` + `X-thinking` hand family: off routes to the bare id, efforts to `-thinking`. */ function thinkingPair(baseId: string, name: string): EffortVariantFamily { return { id: baseId, name, members: [baseId, `${baseId}-thinking`], routing: { off: baseId, [Effort.Minimal]: `${baseId}-thinking`, [Effort.Low]: `${baseId}-thinking`, [Effort.Medium]: `${baseId}-thinking`, [Effort.High]: `${baseId}-thinking`, }, // Thinking-off routes to the non-thinking backing id, where omitting // thinkingConfig is already correct — no suppressWhenOff. thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, preserveAbsentEffortRoutes: true, }; } type DevinTierRoutes = Partial>; /** Devin families with a `-max` sibling: five wire tiers, `low` floor. */ const DEVIN_FIVE_TIER_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max]; /** Pre-5.6 Devin GPT families top out at `-xhigh`: four wire tiers, `low` floor. */ const DEVIN_FOUR_TIER_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]; function devinTierFamily( id: string, name: string, routes: DevinTierRoutes, efforts: readonly Effort[], ): EffortVariantFamily { const routing: Partial> = {}; if (routes.off) routing.off = routes.off; for (const effort of efforts) { switch (effort) { case Effort.Minimal: if (routes.minimal) routing[effort] = routes.minimal; break; case Effort.Low: if (routes.low) routing[effort] = routes.low; break; case Effort.Medium: if (routes.medium) routing[effort] = routes.medium; break; case Effort.High: if (routes.high) routing[effort] = routes.high; break; case Effort.XHigh: if (routes.xhigh) routing[effort] = routes.xhigh; break; case Effort.Max: if (routes.max) routing[effort] = routes.max; break; } } const members = [ routes.off, routes.minimal, routes.low, routes.medium, routes.high, routes.xhigh, routes.max, ].filter((member, index, items): member is string => typeof member === "string" && items.indexOf(member) === index); return { id, name, members, routing, thinking: { mode: "effort", efforts, ...(routes.off ? undefined : { requiresEffort: true }), }, }; } /** * GPT-5.6 (Luna/Sol/Terra) serves per-tier siblings for the full five-tier * `low..max` wire scale in both standard and fast lanes. */ function devinGpt56Families(variant: "luna" | "sol" | "terra", name: string): readonly EffortVariantFamily[] { const base = `gpt-5-6-${variant}`; return [ devinTierFamily( base, name, { off: `${base}-none`, low: `${base}-low`, medium: `${base}-medium`, high: `${base}-high`, xhigh: `${base}-xhigh`, max: `${base}-max`, }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( `${base}-fast`, `${name} Fast`, { off: `${base}-none-priority`, low: `${base}-low-priority`, medium: `${base}-medium-priority`, high: `${base}-high-priority`, xhigh: `${base}-xhigh-priority`, max: `${base}-max-priority`, }, DEVIN_FIVE_TIER_EFFORTS, ), ]; } const GEMINI_3_FLASH_FAMILY_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]; const GEMINI_3_PRO_FAMILY_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High]; /** * Antigravity Cloud Code Assist sends an explicit `thinkingBudget` per tier * (verified against captured `daily-cloudcode-pa` requests). Flash uses round * budgets; Pro offsets every budget by +1. Minimal mirrors Low (the Antigravity * UI exposes Low/Medium/High only) so the effort stays selectable. */ const GEMINI_3_FLASH_FAMILY_BUDGETS: Readonly>> = { [Effort.Minimal]: 1000, [Effort.Low]: 1000, [Effort.Medium]: 4000, [Effort.High]: 10000, }; const GEMINI_3_PRO_FAMILY_BUDGETS: Readonly>> = { [Effort.Low]: 1001, [Effort.High]: 10001, }; /** * Cloud Code Assist's legacy Gemini 3.5 Flash and 3.1 Pro families use * different thinking transports: `google-antigravity` (daily-cloudcode-pa) * sends captured `thinkingBudget` values, while `google-gemini-cli` * (cloudcode-pa) follows the official Gemini CLI and uses `thinkingLevel`. * Gemini 3.6 exposes one wire id per level and uses `thinkingLevel` on both * endpoints. */ function geminiFlashFamily(mode: "budget" | "google-level"): EffortVariantFamily { const budget = mode === "budget"; return { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", members: ["gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3-flash-agent"], routing: budget ? { off: "gemini-3.5-flash-extra-low", [Effort.Minimal]: "gemini-3.5-flash-extra-low", [Effort.Low]: "gemini-3.5-flash-extra-low", [Effort.Medium]: "gemini-3.5-flash-low", [Effort.High]: "gemini-3-flash-agent", } : { off: "gemini-3.5-flash-extra-low", [Effort.Minimal]: "gemini-3-flash-agent", [Effort.Low]: "gemini-3.5-flash-extra-low", [Effort.Medium]: "gemini-3.5-flash-extra-low", [Effort.High]: "gemini-3.5-flash-low", }, thinking: budget ? { mode: "budget", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS, effortBudgets: GEMINI_3_FLASH_FAMILY_BUDGETS } : { mode: "google-level", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS }, suppressWhenOff: true, // Retired bare id; the alias only fires when no live model holds it // (exact match wins in every resolver). extraAliases: ["gemini-3-flash"], }; } const GEMINI_36_FLASH_FAMILY: EffortVariantFamily = { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash", members: ["gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-high", "gemini-3.6-flash-tiered"], routing: { [Effort.Minimal]: "gemini-3.6-flash-low", [Effort.Low]: "gemini-3.6-flash-low", [Effort.Medium]: "gemini-3.6-flash-medium", [Effort.High]: "gemini-3.6-flash-high", }, thinking: { mode: "google-level", efforts: GEMINI_3_FLASH_FAMILY_EFFORTS, requiresEffort: true, }, }; function geminiProFamily(mode: "budget" | "google-level"): EffortVariantFamily { const budget = mode === "budget"; return { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", // High routes to `gemini-pro-agent` — the upstream `gemini-3.1-pro-high` // deployment returns INVALID_ARGUMENT on every streamGenerateContent // request (both CCA endpoints) while discovery still lists it; // `gemini-pro-agent` is the same model ("Gemini 3.1 Pro (High)", same // thinking budget/caps) and accepts the identical request body. // `gemini-3.1-pro-high` stays a member so the dead raw id is consumed. members: ["gemini-3.1-pro-low", "gemini-pro-agent", "gemini-3.1-pro-high"], retiredMembers: ["gemini-3.1-pro-high"], routing: { off: "gemini-3.1-pro-low", [Effort.Low]: "gemini-3.1-pro-low", [Effort.High]: "gemini-pro-agent", }, thinking: budget ? { mode: "budget", efforts: GEMINI_3_PRO_FAMILY_EFFORTS, effortBudgets: GEMINI_3_PRO_FAMILY_BUDGETS } : { mode: "google-level", efforts: GEMINI_3_PRO_FAMILY_EFFORTS }, suppressWhenOff: true, }; } /** CCA families shared verbatim by both providers (transport-agnostic). */ const SHARED_CCA_FAMILIES: readonly EffortVariantFamily[] = [ { // Legacy static family — covers stale snapshots and caches. Stale ids are // unverified against the budget-mode CCA contract; keep them on level. id: "gemini-3-pro", name: "Gemini 3 Pro", members: ["gemini-3-pro-low", "gemini-3-pro-high"], routing: { off: "gemini-3-pro-low", [Effort.Low]: "gemini-3-pro-low", [Effort.High]: "gemini-3-pro-high", }, thinking: { mode: "google-level", efforts: GEMINI_3_PRO_FAMILY_EFFORTS }, suppressWhenOff: true, }, { // Rename-only collapse: every effort and off fall back to the wire id. id: "gpt-oss-120b", name: "GPT-OSS 120B", members: ["gpt-oss-120b-medium"], routing: {}, thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, }, // Antigravity Cloud Code Assist exposes Claude 4.6 asymmetrically: only the // bare `claude-sonnet-4-6` wire id (no `-thinking` twin) and only the // `claude-opus-4-6-thinking` wire id (no bare twin). Per-effort thinking is // carried in the request body via `thinkingBudget`, so both ids accept on/off // requests. Listing both candidates in `members` (priority order) keeps the // collapse correct if the backend mix ever rebalances; `retiredMembers` // re-points stale collapsed snapshots (bundled catalog rows, cache rows // written by prior generations) away from the dead wire id via // `reconcileRetiredRouting`. { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", members: ["claude-sonnet-4-6", "claude-sonnet-4-6-thinking"], retiredMembers: ["claude-sonnet-4-6-thinking"], routing: {}, thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, }, { id: "claude-opus-4-6", name: "Claude Opus 4.6", members: ["claude-opus-4-6-thinking", "claude-opus-4-6"], retiredMembers: ["claude-opus-4-6"], routing: {}, thinking: { mode: "budget", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }, }, thinkingPair("claude-sonnet-4-5", "Claude Sonnet 4.5"), thinkingPair("claude-opus-4-5", "Claude Opus 4.5"), thinkingPair("gemini-2.5-flash", "Gemini 2.5 Flash"), ]; /** `google-antigravity` Gemini families, using each generation's native transport. */ export const ANTIGRAVITY_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = { families: [GEMINI_36_FLASH_FAMILY, geminiFlashFamily("budget"), geminiProFamily("budget"), ...SHARED_CCA_FAMILIES], }; /** `google-gemini-cli` Gemini families on the official CLI's level transport. */ export const GEMINI_CLI_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = { families: [ GEMINI_36_FLASH_FAMILY, geminiFlashFamily("google-level"), geminiProFamily("google-level"), ...SHARED_CCA_FAMILIES, ], }; export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = { families: [ devinTierFamily( "claude-opus-5", "Claude Opus 5", { low: "claude-opus-5-low", medium: "claude-opus-5-medium", high: "claude-opus-5-high", xhigh: "claude-opus-5-xhigh", max: "claude-opus-5-max", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-opus-5-fast", "Claude Opus 5 Fast", { low: "claude-opus-5-low-fast", medium: "claude-opus-5-medium-fast", high: "claude-opus-5-high-fast", xhigh: "claude-opus-5-xhigh-fast", max: "claude-opus-5-max-fast", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-fable-5", "Claude Fable 5", { low: "claude-5-fable-low", medium: "claude-5-fable-medium", high: "claude-5-fable-high", xhigh: "claude-5-fable-xhigh", max: "claude-5-fable-max", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-sonnet-5", "Claude Sonnet 5", { low: "claude-sonnet-5-low", medium: "claude-sonnet-5-medium", high: "claude-sonnet-5-high", xhigh: "claude-sonnet-5-xhigh", max: "claude-sonnet-5-max", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-opus-4-7", "Claude Opus 4.7", { low: "claude-opus-4-7-low", medium: "claude-opus-4-7-medium", high: "claude-opus-4-7-high", xhigh: "claude-opus-4-7-xhigh", max: "claude-opus-4-7-max", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-opus-4-7-fast", "Claude Opus 4.7 Fast", { low: "claude-opus-4-7-low-fast", medium: "claude-opus-4-7-medium-fast", high: "claude-opus-4-7-high-fast", xhigh: "claude-opus-4-7-xhigh-fast", max: "claude-opus-4-7-max-fast", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-opus-4-8", "Claude Opus 4.8", { low: "claude-opus-4-8-low", medium: "claude-opus-4-8-medium", high: "claude-opus-4-8-high", xhigh: "claude-opus-4-8-xhigh", max: "claude-opus-4-8-max", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "claude-opus-4-8-fast", "Claude Opus 4.8 Fast", { low: "claude-opus-4-8-low-fast", medium: "claude-opus-4-8-medium-fast", high: "claude-opus-4-8-high-fast", xhigh: "claude-opus-4-8-xhigh-fast", max: "claude-opus-4-8-max-fast", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "gpt-5-2", "GPT-5.2", { off: "MODEL_GPT_5_2_NONE", low: "MODEL_GPT_5_2_LOW", medium: "MODEL_GPT_5_2_MEDIUM", high: "MODEL_GPT_5_2_HIGH", xhigh: "MODEL_GPT_5_2_XHIGH", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-3-codex", "GPT-5.3 Codex", { low: "gpt-5-3-codex-low", medium: "gpt-5-3-codex-medium", high: "gpt-5-3-codex-high", xhigh: "gpt-5-3-codex-xhigh", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-3-codex-fast", "GPT-5.3 Codex Fast", { low: "gpt-5-3-codex-low-priority", medium: "gpt-5-3-codex-medium-priority", high: "gpt-5-3-codex-high-priority", xhigh: "gpt-5-3-codex-xhigh-priority", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-4", "GPT-5.4", { off: "gpt-5-4-none", low: "gpt-5-4-low", medium: "gpt-5-4-medium", high: "gpt-5-4-high", xhigh: "gpt-5-4-xhigh", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-4-fast", "GPT-5.4 Fast", { off: "gpt-5-4-none-priority", low: "gpt-5-4-low-priority", medium: "gpt-5-4-medium-priority", high: "gpt-5-4-high-priority", xhigh: "gpt-5-4-xhigh-priority", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-4-mini", "GPT-5.4 Mini", { low: "gpt-5-4-mini-low", medium: "gpt-5-4-mini-medium", high: "gpt-5-4-mini-high", xhigh: "gpt-5-4-mini-xhigh", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-5", "GPT-5.5", { off: "gpt-5-5-none", low: "gpt-5-5-low", medium: "gpt-5-5-medium", high: "gpt-5-5-high", xhigh: "gpt-5-5-xhigh", }, DEVIN_FOUR_TIER_EFFORTS, ), devinTierFamily( "gpt-5-5-fast", "GPT-5.5 Fast", { off: "gpt-5-5-none-priority", low: "gpt-5-5-low-priority", medium: "gpt-5-5-medium-priority", high: "gpt-5-5-high-priority", xhigh: "gpt-5-5-xhigh-priority", }, DEVIN_FOUR_TIER_EFFORTS, ), ...devinGpt56Families("luna", "GPT-5.6 Luna"), ...devinGpt56Families("sol", "GPT-5.6 Sol"), ...devinGpt56Families("terra", "GPT-5.6 Terra"), devinTierFamily( "kimi-k3", "Kimi K3", { low: "kimi-k3-low", high: "kimi-k3-high", max: "kimi-k3-max", }, [Effort.Low, Effort.High, Effort.Max], ), devinTierFamily( "swe-1-7", "SWE-1.7", { medium: "swe-1-7-medium", max: "swe-1-7", }, [Effort.Medium, Effort.Max], ), devinTierFamily( "grok-4-5", "Grok 4.5", { low: "grok-4-5-low", medium: "grok-4-5-medium", high: "grok-4-5-high", }, [Effort.Low, Effort.Medium, Effort.High], ), devinTierFamily( "inkling", "Inkling", { off: "inkling-none", low: "inkling-low", medium: "inkling-medium", high: "inkling-high", xhigh: "inkling-xhigh", max: "inkling-max", }, DEVIN_FIVE_TIER_EFFORTS, ), devinTierFamily( "gemini-3-1-pro", "Gemini 3.1 Pro", { low: "gemini-3-1-pro-low", high: "gemini-3-1-pro-high", }, [Effort.Low, Effort.High], ), devinTierFamily( "gemini-3-5-flash", "Gemini 3.5 Flash", { minimal: "gemini-3-5-flash-minimal", low: "gemini-3-5-flash-low", medium: "gemini-3-5-flash-medium", high: "gemini-3-5-flash-high", }, [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], ), devinTierFamily( "gemini-3-6-flash", "Gemini 3.6 Flash", { minimal: "gemini-3-6-flash-minimal", low: "gemini-3-6-flash-low", medium: "gemini-3-6-flash-medium", high: "gemini-3-6-flash-high", }, [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], ), devinTierFamily( "gemini-3-flash", "Gemini 3 Flash", { minimal: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", low: "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW", medium: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM", high: "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", }, [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], ), // GLM-5.2 200K — only the base wire UID `glm-5-2` is free on Devin's // Coding Plan (verified via streamDevin: `glm-5-2-none` and `glm-5-2-max` // both return "weekly usage quota exhausted" while `glm-5-2` streams // successfully). Route every effort to `glm-5-2` so the collapsed entry // is always free; include the paid 200K variants as members so they are // hidden from the model list. The 1M-context variants stay as separate // paid entries (collapsed below). { id: "glm-5-2", name: "GLM-5.2", members: ["glm-5-2", "glm-5-2-none", "glm-5-2-max"], routing: { [Effort.High]: "glm-5-2", [Effort.XHigh]: "glm-5-2", }, thinking: { mode: "effort", efforts: [Effort.High, Effort.XHigh], requiresEffort: true, }, }, // GLM-5.2 1M — paid variants that consume weekly quota. Collapse the // three 1M-context variants into one entry with proper effort routing. devinTierFamily( "glm-5-2-1m", "GLM-5.2 1M", { off: "glm-5-2-none-1m", high: "glm-5-2-1m", xhigh: "glm-5-2-max-1m", }, [Effort.High, Effort.XHigh], ), ], }; /** Provider id → hand collapse table. The CCA providers diverge on thinking transport. */ export const VARIANT_COLLAPSE_TABLES: Readonly> = { "google-antigravity": ANTIGRAVITY_VARIANT_COLLAPSE_TABLE, "google-gemini-cli": GEMINI_CLI_VARIANT_COLLAPSE_TABLE, devin: DEVIN_VARIANT_COLLAPSE_TABLE, }; /** * The global automatic rule: derive an `X` + `X-thinking` family for every * pair where both ids are live in `specs` (trailing or infix token). Gates: * - both members share the same `api`, * - known pricing must match — all-zero cost rows count as unknown * (aggregators routinely ship them), but twins that BOTH carry real, * differing prices are distinct SKUs and never merge, * - ids claimed by the provider's hand `table` are skipped (curation wins). * The capability surface prefers the thinking member's metadata, then the * bare member's, then the canonical deriver (aggregators often ship * `reasoning: false` and no thinking config on the twin), then a budget * default. `off` routes to the bare id; every supported effort routes to the * thinking id. */ export function deriveThinkingPairFamilies( specs: readonly TSpec[], table?: VariantCollapseTable, ): EffortVariantFamily[] { const byId = new Map(); for (const spec of specs) { if (!byId.has(spec.id)) byId.set(spec.id, spec); } const claimed = table ? getAliasIndex(table) : undefined; const families: EffortVariantFamily[] = []; for (const spec of specs) { const baseId = stripThinkingVariantToken(spec.id); if (baseId === undefined || baseId === spec.id) continue; const base = byId.get(baseId); if (!base) continue; if (claimed) { const forward = claimed.forward; if ( forward.has(spec.id.toLowerCase()) || forward.has(baseId.toLowerCase()) || claimed.familyIds.has(spec.id) || claimed.familyIds.has(baseId) ) { continue; } } if (spec.api !== base.api) continue; const specPriced = spec.cost.input !== 0 || spec.cost.output !== 0; const basePriced = base.cost.input !== 0 || base.cost.output !== 0; if ( specPriced && basePriced && (spec.cost.input !== base.cost.input || spec.cost.output !== base.cost.output || spec.cost.cacheRead !== base.cost.cacheRead || spec.cost.cacheWrite !== base.cost.cacheWrite) ) { continue; } const surface = derivePairThinkingSurface(spec, base); const routing: Partial> = { off: base.id }; for (const effort of surface.efforts) { routing[effort] = spec.id; } families.push({ id: base.id, name: base.name, members: [base.id, spec.id], routing, thinking: surface, }); } return families; } const DEFAULT_PAIR_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]; /** * Surface fallback chain: thinking member → bare member → canonical deriver → * budget default. `requiresEffort` is dropped from every source: the COLLAPSED * pair can disable thinking (off routes to the bare backing id), even though * the thinking member alone cannot. */ function derivePairThinkingSurface( thinkingSpec: VariantSpecLike, baseSpec: VariantSpecLike, ): Omit { const baked = thinkingSpec.thinking ?? baseSpec.thinking; if (baked && baked.efforts.length > 0) { const { effortRouting: _routing, suppressWhenOff: _suppress, requiresEffort: _required, ...surface } = baked; return surface; } const derived = resolveModelThinking( { ...(thinkingSpec as unknown as ModelSpec), reasoning: true, thinking: undefined }, buildCompat(thinkingSpec as unknown as ModelSpec), ); if (derived && derived.efforts.length > 0) { const { effortRouting: _dRouting, suppressWhenOff: _dSuppress, requiresEffort: _dRequired, ...surface } = derived; return surface; } return { mode: "budget", efforts: DEFAULT_PAIR_EFFORTS }; } /** * True when `spec` is the output of collapsing rather than a raw upstream * member. `thinking.effortRouting` is written only by collapsing; the * `requestModelId` arm is scoped to the provider's hand-table family ids so * unrelated carriers (GitHub Copilot `-1m` context variants) never match. */ export function isVariantCollapsedSpec(spec: VariantSpecLike): boolean { if (spec.thinking?.effortRouting !== undefined) { return true; } if (spec.requestModelId === undefined) { return false; } const table = VARIANT_COLLAPSE_TABLES[spec.provider]; return table !== undefined && getAliasIndex(table).familyIds.has(spec.id); } /** * Re-point a stale collapsed spec whose `requestModelId` or routing still * targets a retired wire id. Collapsed snapshots (bundled catalog, cache * rows, previous-generation fallbacks) pass through collapsing untouched, so * a hand-table routing fix would otherwise never reach them. Only retired * targets are rewritten — presence-filtered routing decisions from live * discovery stay authoritative for everything else. Per retired entry the * table's route for that effort wins, then the off/first-live-member wire id, * then the route is dropped (falls back to `requestModelId ?? id`). Returns * `spec` by reference when nothing targets a retired id. */ function reconcileRetiredRouting( spec: TSpec, family: EffortVariantFamily, retired: ReadonlySet, ): TSpec { const routing = spec.thinking?.effortRouting; const requestRetired = spec.requestModelId !== undefined && retired.has(spec.requestModelId); let routingRetired = false; if (routing !== undefined) { for (const key in routing) { const target = routing[key as Effort | "off"]; if (target !== undefined && retired.has(target)) { routingRetired = true; break; } } } if (!requestRetired && !routingRetired) return spec; const offTarget = family.routing.off; const fallbackWireId = offTarget !== undefined && !retired.has(offTarget) ? offTarget : family.members.find(id => !retired.has(id)); const next: TSpec = { ...spec }; if (routingRetired && routing !== undefined) { const nextRouting: Partial> = {}; for (const key in routing) { const effortKey = key as Effort | "off"; const target = routing[effortKey]; if (target === undefined) continue; if (!retired.has(target)) { nextRouting[effortKey] = target; continue; } const tableTarget = family.routing[effortKey]; if (tableTarget !== undefined && !retired.has(tableTarget)) { nextRouting[effortKey] = tableTarget; } else if (fallbackWireId !== undefined) { nextRouting[effortKey] = fallbackWireId; } } next.thinking = { ...(spec.thinking as ThinkingConfig), effortRouting: nextRouting }; } if (requestRetired) { if (fallbackWireId !== undefined && fallbackWireId !== spec.id) { next.requestModelId = fallbackWireId; } else { delete next.requestModelId; } } return next; } /** * Refresh a collapsed snapshot's thinking surface in place. Bundled catalog and * prev-generation snapshots freeze a family's transport, budgets, and routing; * discovery emits the canonical id but the exact-id merge never overwrites a * stale `family.id` row (e.g. `gemini-3.1-pro`) nor a recycled `extraAliases` * row (e.g. `gemini-3-flash`). This re-applies the hand-table family's thinking, * routing, and default wire id while keeping the spec id (load-bearing for exact * selectors and bundled lookups). Returns `spec` by reference when unchanged. */ function refreshCollapsedThinking( spec: TSpec, family: EffortVariantFamily, retired: ReadonlySet | undefined, ): TSpec { // Scope snapshot self-heal to families carrying a curated per-effort budget // contract (Antigravity gemini-3.x). Their routing targets are all verified // live, so rebuilding routing here is safe; families without `effortBudgets` // (derived `X`/`X-thinking` pairs, claude pairs) keep their presence-filtered // snapshot routing untouched. if (!spec.reasoning || family.thinking.effortBudgets === undefined) return spec; const routing: Partial> = {}; let hasRouting = false; for (const effortKey in family.routing) { const target = family.routing[effortKey as Effort | "off"]; if (target !== undefined && !retired?.has(target)) { routing[effortKey as Effort | "off"] = target; hasRouting = true; } } const thinking: ThinkingConfig = { ...family.thinking }; if (hasRouting) thinking.effortRouting = routing; if (family.suppressWhenOff) thinking.suppressWhenOff = true; const offTarget = family.routing.off; const requestModelId = offTarget !== undefined && !retired?.has(offTarget) && offTarget !== spec.id ? offTarget : spec.requestModelId; if (Bun.deepEquals(thinking, spec.thinking) && requestModelId === spec.requestModelId) { return spec; } return { ...spec, thinking, ...(requestModelId !== undefined ? { requestModelId } : {}) }; } /** * Collapse every family in `table` found in `specs`. Non-member specs pass * through verbatim (by reference), order preserved; the collapsed spec * replaces the first occurrence of its family. */ export function collapseEffortVariants( specs: readonly TSpec[], table: VariantCollapseTable, ): TSpec[] { const byId = new Map(); for (const spec of specs) { if (!byId.has(spec.id)) byId.set(spec.id, spec); } /** family id → spec to emit at the family's first occurrence. */ const replacement = new Map(); /** spec ids that belong to a touched family (members + logical id). */ const familyIdBySpecId = new Map(); for (const family of table.families) { const retired = family.retiredMembers !== undefined && family.retiredMembers.length > 0 ? new Set(family.retiredMembers) : undefined; const existing = byId.get(family.id); const existingCollapsed = existing !== undefined && (existing.requestModelId !== undefined || existing.thinking?.effortRouting !== undefined); const reconciled = existing !== undefined && existingCollapsed && retired !== undefined ? reconcileRetiredRouting(existing, family, retired) : existing; const rawPresent = family.members.filter(id => byId.has(id) && !(id === family.id && existingCollapsed)); if (rawPresent.length === 0) { // Inert (no members) or already collapsed (pass-through). A stale // family.id-keyed snapshot is refreshed in place from the current // hand-table family (transport/budgets/routing); retired targets drop. // Recycled extraAliases rows are healed in a later pass. const refreshed = existing !== undefined && existingCollapsed ? refreshCollapsedThinking(reconciled ?? existing, family, retired) : reconciled; if (refreshed !== undefined && refreshed !== existing) { familyIdBySpecId.set(family.id, family.id); replacement.set(family.id, refreshed); } continue; } for (const id of rawPresent) familyIdBySpecId.set(id, family.id); if (existing) familyIdBySpecId.set(family.id, family.id); if (existingCollapsed) { // Mixed input: the collapsed entry (live truth) wins; stale raw // members are deduped away. Retired targets are re-pointed first. replacement.set(family.id, reconciled as TSpec); continue; } const memberSpecs = rawPresent.map(id => byId.get(id) as TSpec); const presentSet = new Set(rawPresent); const routing: Partial> = {}; let hasRouting = false; let hasEffortRoute = false; let usedAbsentEffortRoute = false; for (const effortKey in family.routing) { const target = family.routing[effortKey as Effort | "off"]; const effort = effortKey as Effort | "off"; const targetPresent = target !== undefined && presentSet.has(target); const preserveAbsentEffort = target !== undefined && effort !== "off" && family.preserveAbsentEffortRoutes === true; if (target !== undefined && (targetPresent || preserveAbsentEffort) && !retired?.has(target)) { routing[effort] = target; hasRouting = true; if (effortKey !== "off") hasEffortRoute = true; if (!targetPresent && effort !== "off") usedAbsentEffortRoute = true; } } // A family that routes efforts to a live thinking backing id reasons // even when upstream metadata forgot to mark the members. const reasoning = memberSpecs.some(spec => spec.reasoning) || hasEffortRoute; const thinking: ThinkingConfig = { ...family.thinking }; if (hasRouting) thinking.effortRouting = routing; if (family.suppressWhenOff) thinking.suppressWhenOff = true; const input: ("text" | "image")[] = []; if (memberSpecs.some(spec => spec.input.includes("text"))) input.push("text"); if (memberSpecs.some(spec => spec.input.includes("image"))) input.push("image"); const collapsed: TSpec = { ...(memberSpecs[0] as TSpec), id: family.id, name: family.name, reasoning, input, contextWindow: maxOrNull(memberSpecs.map(spec => spec.contextWindow)), maxTokens: maxOrNull(memberSpecs.map(spec => spec.maxTokens)), }; // The default wire id is the highest-priority live member; omit when it // equals the logical id (bare/thinking pairs) — `resolveWireModelId` // falls back. Retired members never become the default. const defaultWireId = rawPresent.find(id => !retired?.has(id)) ?? rawPresent[0]; if (defaultWireId === family.id) { if (usedAbsentEffortRoute) { collapsed.requestModelId = defaultWireId as string; } else { delete collapsed.requestModelId; } } else { collapsed.requestModelId = defaultWireId as string; } if (reasoning) { collapsed.thinking = thinking; } else { delete collapsed.thinking; } replacement.set(family.id, collapsed); } // Refresh stale alias-keyed snapshots in place (recycled bare ids). Runs even // when the canonical family.id row is also present, since the exact-id merge // keeps the stale alias row alongside the discovered canonical one. for (const family of table.families) { if (family.extraAliases === undefined) continue; const retired = family.retiredMembers !== undefined && family.retiredMembers.length > 0 ? new Set(family.retiredMembers) : undefined; for (const alias of family.extraAliases) { if (alias === family.id || familyIdBySpecId.has(alias)) continue; const aliasSpec = byId.get(alias); if (aliasSpec === undefined) continue; const refreshed = refreshCollapsedThinking(aliasSpec, family, retired); if (refreshed !== aliasSpec) { familyIdBySpecId.set(alias, alias); replacement.set(alias, refreshed); } } } if (replacement.size === 0) return [...specs]; const emitted = new Set(); const out: TSpec[] = []; for (const spec of specs) { const familyId = familyIdBySpecId.get(spec.id); if (familyId === undefined) { out.push(spec); continue; } if (emitted.has(familyId)) continue; emitted.add(familyId); out.push(replacement.get(familyId) as TSpec); } return out; } /** * Collapse a full mixed-provider list: per provider, the hand table (when * registered) plus the automatic `X`/`X-thinking` pair rule. Used by the * catalog generator; the runtime equivalent lives at the model-manager merge * point. Output is regrouped by provider — callers re-sort. */ export function collapseEffortVariantsAcrossProviders(specs: readonly TSpec[]): TSpec[] { const byProvider = new Map(); for (const spec of specs) { const slice = byProvider.get(spec.provider); if (slice) { slice.push(spec); } else { byProvider.set(spec.provider, [spec]); } } const out: TSpec[] = []; for (const [provider, slice] of byProvider) { const table = VARIANT_COLLAPSE_TABLES[provider]; let result = table ? collapseEffortVariants(slice, table) : slice; const derived = deriveThinkingPairFamilies(result, table); if (derived.length > 0) { result = collapseEffortVariants(result, { families: derived }); } out.push(...result); } return out; } /** * Runtime entry point for already-built `Model` lists (the model-manager * merge point, coding-agent registry custom providers): collapses hand * tables plus derived pairs, then re-runs `buildModel` on freshly created * logical specs so thinking wire defaults stay resolved. Untouched entries * pass through by reference. */ export function collapseBuiltModelVariants(models: readonly Model[]): Model[] { const collapsed = collapseEffortVariantsAcrossProviders(models); const inputRefs = new Set>(models); return collapsed.map(model => // Rebuild from a projected spec (sparse compatConfig) instead of resolved compat. inputRefs.has(model) ? model : buildModel({ ...model, compat: model.compatConfig } as unknown as ModelSpec), ); } interface VariantAliasIndex { /** lowercased retired id → replacement model id. */ forward: Map; /** replacement model id → retired ids that resolve to it. */ reverse: Map; /** Collapsed logical ids declared by the table. */ familyIds: Set; } const kAliasIndex = Symbol("variant-collapse.aliasIndex"); interface TableWithAliasIndex extends VariantCollapseTable { [kAliasIndex]?: VariantAliasIndex; } function getAliasIndex(table: VariantCollapseTable): VariantAliasIndex { const tagged = table as TableWithAliasIndex; const cached = tagged[kAliasIndex]; if (cached) return cached; const forward = new Map(); const reverse = new Map(); const add = (from: string, to: string) => { if (from === to) return; forward.set(from.toLowerCase(), to); const sources = reverse.get(to); if (sources) { sources.push(from); } else { reverse.set(to, [from]); } }; const familyIds = new Set(); for (const family of table.families) { familyIds.add(family.id); for (const member of family.members) add(member, family.id); for (const alias of family.extraAliases ?? []) add(alias, family.id); } const index: VariantAliasIndex = { forward, reverse, familyIds }; tagged[kAliasIndex] = index; return index; } /** * Resolve a retired effort-tier variant id (collapsed member, recycled id) to * its replacement model id for `provider` via the hand table. Returns * `undefined` when the id is not a known alias; derived `X-thinking` members * resolve through `stripThinkingVariantToken` instead. Callers must try an * exact model lookup first — a live model always wins over an alias. */ export function resolveVariantAlias(provider: Provider, modelId: string): string | undefined { const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[provider.toLowerCase()]; if (!table) return undefined; return getAliasIndex(table).forward.get(modelId.trim().toLowerCase()); } /** Bare-id alias hit: replacement id plus the providers declaring it. */ export interface BareVariantAliasHit { id: string; /** Providers whose table declares the alias — candidates from these win ties. */ providers: readonly Provider[]; } /** * Provider-agnostic hand-table alias lookup for bare-id selectors. Returns * the declaring providers so callers can prefer their models when the * replacement id exists on unrelated providers too (e.g. a retired Cursor * tier id must not resolve to `openai/gpt-5.4`). */ export function resolveBareVariantAlias(modelId: string): BareVariantAliasHit | undefined { const normalized = modelId.trim().toLowerCase(); for (const provider in VARIANT_COLLAPSE_TABLES) { const table = VARIANT_COLLAPSE_TABLES[provider] as VariantCollapseTable; const hit = getAliasIndex(table).forward.get(normalized); if (hit === undefined) continue; const providers: Provider[] = []; for (const candidate in VARIANT_COLLAPSE_TABLES) { // Match by resolved alias target, not table identity: the CCA providers // now hold distinct table objects that still share these aliases. if ( getAliasIndex(VARIANT_COLLAPSE_TABLES[candidate] as VariantCollapseTable).forward.get(normalized) === hit ) { providers.push(candidate); } } return { id: hit, providers }; } return undefined; } /** * Reverse alias lookup: the retired ids that resolve to `modelId` for * `provider` via the hand table. Used to re-key config keyed by raw member * ids (models.yml `modelOverrides`, suppressed selectors) onto the collapsed * model. Empty for providers without a table. */ export function getVariantAliasSources(provider: Provider, modelId: string): readonly string[] { const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[provider.toLowerCase()]; if (!table) return []; return getAliasIndex(table).reverse.get(modelId) ?? []; } function maxOrNull(values: ReadonlyArray): number | null { const known = values.filter((v): v is number => v != null); return known.length ? Math.max(...known) : null; }