/** * Model catalog — merged from hardcoded enum values + live GetCascadeModelConfigs. * * Routing logic: * modelUid present → Cascade flow (StartCascade → SendUserCascadeMessage) * only enumValue>0 → RawGetChatMessage (legacy) * * Credit multipliers sourced from GetCascadeModelConfigs (server.codeium.com). * Enum values sourced from Windsurf extension.js decompilation. */ import { getBackendSwitch } from './runtime-config.js'; export const MODELS = { // ── Claude ────────────────────────────────────────────── // Legacy 3.5 / 3.7 series — only have enumValue (legacy RawGetChatMessage flow), no modelUid. // Cascade upstream returns "neither PlanModel nor RequestedModel specified" for all three; // chat.js translates that to 410 model_deprecated when the catalog flag is set. issue #109. 'claude-3.5-sonnet': { name: 'claude-3.5-sonnet', provider: 'anthropic', enumValue: 166, credit: 2, deprecated: true }, 'claude-3.7-sonnet': { name: 'claude-3.7-sonnet', provider: 'anthropic', enumValue: 226, credit: 2, deprecated: true }, 'claude-3.7-sonnet-thinking': { name: 'claude-3.7-sonnet-thinking', provider: 'anthropic', enumValue: 227, credit: 3, deprecated: true }, 'claude-4-sonnet': { name: 'claude-4-sonnet', provider: 'anthropic', enumValue: 281, modelUid: 'MODEL_CLAUDE_4_SONNET', credit: 2 }, 'claude-4-sonnet-thinking': { name: 'claude-4-sonnet-thinking', provider: 'anthropic', enumValue: 282, modelUid: 'MODEL_CLAUDE_4_SONNET_THINKING', credit: 3 }, 'claude-4-opus': { name: 'claude-4-opus', provider: 'anthropic', enumValue: 290, modelUid: 'MODEL_CLAUDE_4_OPUS', credit: 4 }, 'claude-4-opus-thinking': { name: 'claude-4-opus-thinking', provider: 'anthropic', enumValue: 291, modelUid: 'MODEL_CLAUDE_4_OPUS_THINKING', credit: 5 }, 'claude-4.1-opus': { name: 'claude-4.1-opus', provider: 'anthropic', enumValue: 328, modelUid: 'MODEL_CLAUDE_4_1_OPUS', credit: 4 }, 'claude-4.1-opus-thinking': { name: 'claude-4.1-opus-thinking', provider: 'anthropic', enumValue: 329, modelUid: 'MODEL_CLAUDE_4_1_OPUS_THINKING', credit: 5 }, 'claude-4.5-haiku': { name: 'claude-4.5-haiku', provider: 'anthropic', enumValue: 0, modelUid: 'MODEL_PRIVATE_11', credit: 1 }, 'claude-4.5-sonnet': { name: 'claude-4.5-sonnet', provider: 'anthropic', enumValue: 353, modelUid: 'MODEL_PRIVATE_2', credit: 2 }, 'claude-4.5-sonnet-thinking': { name: 'claude-4.5-sonnet-thinking', provider: 'anthropic', enumValue: 354, modelUid: 'MODEL_PRIVATE_3', credit: 3 }, 'claude-4.5-opus': { name: 'claude-4.5-opus', provider: 'anthropic', enumValue: 391, modelUid: 'MODEL_CLAUDE_4_5_OPUS', credit: 4 }, 'claude-4.5-opus-thinking': { name: 'claude-4.5-opus-thinking', provider: 'anthropic', enumValue: 392, modelUid: 'MODEL_CLAUDE_4_5_OPUS_THINKING', credit: 5 }, 'claude-sonnet-4.6': { name: 'claude-sonnet-4.6', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-4-6', credit: 4 }, 'claude-sonnet-4.6-thinking': { name: 'claude-sonnet-4.6-thinking', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-4-6-thinking', credit: 6 }, 'claude-sonnet-4.6-1m': { name: 'claude-sonnet-4.6-1m', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-4-6-1m', credit: 12 }, 'claude-sonnet-4.6-thinking-1m': { name: 'claude-sonnet-4.6-thinking-1m', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-4-6-thinking-1m', credit: 16 }, 'claude-opus-4.6': { name: 'claude-opus-4.6', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-6', credit: 6 }, 'claude-opus-4.6-thinking': { name: 'claude-opus-4.6-thinking', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-6-thinking', credit: 8 }, 'claude-opus-4-6-1m': { name: 'claude-opus-4-6-1m', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-6-1m', credit: 10 }, 'claude-opus-4-6-thinking-1m': { name: 'claude-opus-4-6-thinking-1m', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-6-thinking-1m', credit: 12 }, // Claude Opus 4.7 — Windsurf changelog 2026-04-16; new xhigh effort tier vs 4.6. // `medium` is the canonical default; low/high/xhigh/max are reasoning tiers, // each can be paired with -thinking for visible chain-of-thought. 'claude-opus-4-7-medium': { name: 'claude-opus-4-7-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-medium', credit: 40 }, 'claude-opus-4-7-low': { name: 'claude-opus-4-7-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-low', credit: 20 }, 'claude-opus-4-7-high': { name: 'claude-opus-4-7-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-high', credit: 60 }, 'claude-opus-4-7-xhigh': { name: 'claude-opus-4-7-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-xhigh', credit: 65 }, 'claude-opus-4-7-medium-thinking': { name: 'claude-opus-4-7-medium-thinking', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-medium-thinking', credit: 10 }, 'claude-opus-4-7-high-thinking': { name: 'claude-opus-4-7-high-thinking', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-high-thinking', credit: 12 }, 'claude-opus-4-7-xhigh-thinking': { name: 'claude-opus-4-7-xhigh-thinking', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-xhigh-thinking', credit: 16 }, // `max` reasoning tier appeared in GetCascadeModelConfigs after the 4.7 launch — sits // above xhigh in the effort ladder. No -thinking sibling in cloud catalog yet. 'claude-opus-4-7-max': { name: 'claude-opus-4-7-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-7-max', credit: 75 }, // Claude Opus 4.8 — confirmed live in GetCascadeModelConfigs (2026-07-03). // Upstream exposes normal and priority (=fast) lanes across low/medium/high/xhigh/max. 'claude-opus-4-8-low': { name: 'claude-opus-4-8-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-low', credit: 20 }, 'claude-opus-4-8-medium': { name: 'claude-opus-4-8-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-medium', credit: 25 }, 'claude-opus-4-8-high': { name: 'claude-opus-4-8-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-high', credit: 35 }, 'claude-opus-4-8-xhigh': { name: 'claude-opus-4-8-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-xhigh', credit: 40 }, 'claude-opus-4-8-max': { name: 'claude-opus-4-8-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-max', credit: 50 }, 'claude-opus-4-8-low-fast': { name: 'claude-opus-4-8-low-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-low-fast', credit: 40 }, 'claude-opus-4-8-medium-fast': { name: 'claude-opus-4-8-medium-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-medium-fast', credit: 50 }, 'claude-opus-4-8-high-fast': { name: 'claude-opus-4-8-high-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-high-fast', credit: 70 }, 'claude-opus-4-8-xhigh-fast': { name: 'claude-opus-4-8-xhigh-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-xhigh-fast', credit: 80 }, 'claude-opus-4-8-max-fast': { name: 'claude-opus-4-8-max-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-4-8-max-fast', credit: 100 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // Claude Fable 5.1 'claude-fable-5-1-low': { name: 'claude-fable-5-1-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-fable-5-1-low', credit: 150 }, 'claude-fable-5-1-medium': { name: 'claude-fable-5-1-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-fable-5-1-medium', credit: 175 }, 'claude-fable-5-1-high': { name: 'claude-fable-5-1-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-fable-5-1-high', credit: 200 }, 'claude-fable-5-1-xhigh': { name: 'claude-fable-5-1-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-fable-5-1-xhigh', credit: 225 }, 'claude-fable-5-1-max': { name: 'claude-fable-5-1-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-fable-5-1-max', credit: 250 }, // Claude Opus 5.5 'claude-opus-5-5-low': { name: 'claude-opus-5-5-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-5-low', credit: 35 }, 'claude-opus-5-5-medium': { name: 'claude-opus-5-5-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-5-medium', credit: 45 }, 'claude-opus-5-5-high': { name: 'claude-opus-5-5-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-5-high', credit: 65 }, 'claude-opus-5-5-xhigh': { name: 'claude-opus-5-5-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-5-xhigh', credit: 125 }, 'claude-opus-5-5-max': { name: 'claude-opus-5-5-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-5-max', credit: 150 }, // Claude Sonnet 5.5 'claude-sonnet-5-5-low': { name: 'claude-sonnet-5-5-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-5-low', credit: 12 }, 'claude-sonnet-5-5-medium': { name: 'claude-sonnet-5-5-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-5-medium', credit: 15 }, 'claude-sonnet-5-5-high': { name: 'claude-sonnet-5-5-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-5-high', credit: 20 }, 'claude-sonnet-5-5-xhigh': { name: 'claude-sonnet-5-5-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-5-xhigh', credit: 30 }, 'claude-sonnet-5-5-max': { name: 'claude-sonnet-5-5-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-5-max', credit: 40 }, // Claude Haiku 5.5 — live GetCascadeModelConfigs 2026-10-18. 'claude-haiku-5-5-low': { name: 'claude-haiku-5-5-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-haiku-5-5-low', credit: 3 }, 'claude-haiku-5-5-medium': { name: 'claude-haiku-5-5-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-haiku-5-5-medium', credit: 4 }, 'claude-haiku-5-5-high': { name: 'claude-haiku-5-5-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-haiku-5-5-high', credit: 5 }, 'claude-haiku-5-5-xhigh': { name: 'claude-haiku-5-5-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-haiku-5-5-xhigh', credit: 8 }, 'claude-haiku-5-5-max': { name: 'claude-haiku-5-5-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-haiku-5-5-max', credit: 10 }, // ── Claude 5 family ────────────────────────────────────── // fable-5 (2026-06-09) / sonnet-5 (2026-06-30) / opus-5 (2026-07-23) — Devin // catalog, dash-form selectors. fable/sonnet-5 confirmed in the 07-08 live // snapshot; opus-5 + fast lanes from the official price table (docs.devin.ai // 2026-08-11) — selector form follows the 4-8 family (claude-opus-5-*). All // five effort tiers per family, no -thinking siblings in the catalog. // Credit multipliers from the official modelCostData table. 'claude-5-fable-low': { name: 'claude-5-fable-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-5-fable-low', credit: 40 }, 'claude-5-fable-medium': { name: 'claude-5-fable-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-5-fable-medium', credit: 50 }, 'claude-5-fable-high': { name: 'claude-5-fable-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-5-fable-high', credit: 70 }, 'claude-5-fable-xhigh': { name: 'claude-5-fable-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-5-fable-xhigh', credit: 80 }, 'claude-5-fable-max': { name: 'claude-5-fable-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-5-fable-max', credit: 100 }, 'claude-sonnet-5-low': { name: 'claude-sonnet-5-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-low', credit: 12 }, 'claude-sonnet-5-medium': { name: 'claude-sonnet-5-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-medium', credit: 15 }, 'claude-sonnet-5-high': { name: 'claude-sonnet-5-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-high', credit: 20 }, 'claude-sonnet-5-xhigh': { name: 'claude-sonnet-5-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-xhigh', credit: 30 }, 'claude-sonnet-5-max': { name: 'claude-sonnet-5-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-sonnet-5-max', credit: 40 }, 'claude-opus-5-low': { name: 'claude-opus-5-low', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-low', credit: 75 }, 'claude-opus-5-medium': { name: 'claude-opus-5-medium', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-medium', credit: 80 }, 'claude-opus-5-high': { name: 'claude-opus-5-high', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-high', credit: 115 }, 'claude-opus-5-xhigh': { name: 'claude-opus-5-xhigh', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-xhigh', credit: 155 }, 'claude-opus-5-max': { name: 'claude-opus-5-max', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-max', credit: 160 }, 'claude-opus-5-low-fast': { name: 'claude-opus-5-low-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-low-fast', credit: 150 }, 'claude-opus-5-medium-fast': { name: 'claude-opus-5-medium-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-medium-fast', credit: 160 }, 'claude-opus-5-high-fast': { name: 'claude-opus-5-high-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-high-fast', credit: 230 }, 'claude-opus-5-xhigh-fast': { name: 'claude-opus-5-xhigh-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-xhigh-fast', credit: 310 }, 'claude-opus-5-max-fast': { name: 'claude-opus-5-max-fast', provider: 'anthropic', enumValue: 0, modelUid: 'claude-opus-5-max-fast', credit: 320 }, // ── GPT ───────────────────────────────────────────────── 'gpt-4o': { name: 'gpt-4o', provider: 'openai', enumValue: 109, modelUid: 'MODEL_CHAT_GPT_4O_2024_08_06', credit: 1 , deprecated: true }, 'gpt-4o-mini': { name: 'gpt-4o-mini', provider: 'openai', enumValue: 113, credit: 0.5, deprecated: true }, 'gpt-4.1': { name: 'gpt-4.1', provider: 'openai', enumValue: 259, modelUid: 'MODEL_CHAT_GPT_4_1_2025_04_14', credit: 1 , deprecated: true }, 'gpt-4.1-mini': { name: 'gpt-4.1-mini', provider: 'openai', enumValue: 260, credit: 0.5, deprecated: true }, 'gpt-4.1-nano': { name: 'gpt-4.1-nano', provider: 'openai', enumValue: 261, credit: 0.25, deprecated: true }, 'gpt-5': { name: 'gpt-5', provider: 'openai', enumValue: 340, modelUid: 'MODEL_PRIVATE_6', credit: 0.5 }, 'gpt-5-medium': { name: 'gpt-5-medium', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_7', credit: 1 }, 'gpt-5-high': { name: 'gpt-5-high', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_8', credit: 2 }, 'gpt-5-mini': { name: 'gpt-5-mini', provider: 'openai', enumValue: 337, credit: 0.25, deprecated: true }, 'gpt-5-codex': { name: 'gpt-5-codex', provider: 'openai', enumValue: 346, modelUid: 'MODEL_CHAT_GPT_5_CODEX', credit: 0.5 }, // GPT-5.1 'gpt-5.1': { name: 'gpt-5.1', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_12', credit: 0.5 }, 'gpt-5.1-low': { name: 'gpt-5.1-low', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_13', credit: 0.5 }, 'gpt-5.1-medium': { name: 'gpt-5.1-medium', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_14', credit: 1 }, 'gpt-5.1-high': { name: 'gpt-5.1-high', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_15', credit: 2 }, 'gpt-5.1-fast': { name: 'gpt-5.1-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_20', credit: 1 }, 'gpt-5.1-low-fast': { name: 'gpt-5.1-low-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_21', credit: 1 }, 'gpt-5.1-medium-fast': { name: 'gpt-5.1-medium-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_22', credit: 2 }, 'gpt-5.1-high-fast': { name: 'gpt-5.1-high-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_23', credit: 4 }, // GPT-5.1 Codex 'gpt-5.1-codex-low': { name: 'gpt-5.1-codex-low', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_1_CODEX_LOW', credit: 0.5 }, 'gpt-5.1-codex-medium': { name: 'gpt-5.1-codex-medium', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_9', credit: 1 }, 'gpt-5.1-codex-mini-low': { name: 'gpt-5.1-codex-mini-low', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_1_CODEX_MINI_LOW', credit: 0.25 }, 'gpt-5.1-codex-mini': { name: 'gpt-5.1-codex-mini', provider: 'openai', enumValue: 0, modelUid: 'MODEL_PRIVATE_19', credit: 0.5 }, 'gpt-5.1-codex-max-low': { name: 'gpt-5.1-codex-max-low', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_1_CODEX_MAX_LOW', credit: 1 }, 'gpt-5.1-codex-max-medium': { name: 'gpt-5.1-codex-max-medium', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_1_CODEX_MAX_MEDIUM', credit: 1.25 }, 'gpt-5.1-codex-max-high': { name: 'gpt-5.1-codex-max-high', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_1_CODEX_MAX_HIGH', credit: 1.5 }, // GPT-5.2 'gpt-5.2': { name: 'gpt-5.2', provider: 'openai', enumValue: 401, modelUid: 'MODEL_GPT_5_2_MEDIUM', credit: 2 }, 'gpt-5.2-none': { name: 'gpt-5.2-none', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_NONE', credit: 1 }, 'gpt-5.2-low': { name: 'gpt-5.2-low', provider: 'openai', enumValue: 400, modelUid: 'MODEL_GPT_5_2_LOW', credit: 1 }, 'gpt-5.2-high': { name: 'gpt-5.2-high', provider: 'openai', enumValue: 402, modelUid: 'MODEL_GPT_5_2_HIGH', credit: 3 }, 'gpt-5.2-xhigh': { name: 'gpt-5.2-xhigh', provider: 'openai', enumValue: 403, modelUid: 'MODEL_GPT_5_2_XHIGH', credit: 8 }, 'gpt-5.2-none-fast': { name: 'gpt-5.2-none-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_NONE_PRIORITY', credit: 2 }, 'gpt-5.2-low-fast': { name: 'gpt-5.2-low-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_LOW_PRIORITY', credit: 2 }, 'gpt-5.2-medium-fast': { name: 'gpt-5.2-medium-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_MEDIUM_PRIORITY', credit: 4 }, 'gpt-5.2-high-fast': { name: 'gpt-5.2-high-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_HIGH_PRIORITY', credit: 6 }, 'gpt-5.2-xhigh-fast': { name: 'gpt-5.2-xhigh-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_XHIGH_PRIORITY', credit: 16 }, // GPT-5.2 Codex 'gpt-5.2-codex-low': { name: 'gpt-5.2-codex-low', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_LOW', credit: 1 }, 'gpt-5.2-codex-medium': { name: 'gpt-5.2-codex-medium', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_MEDIUM', credit: 1 }, 'gpt-5.2-codex-high': { name: 'gpt-5.2-codex-high', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_HIGH', credit: 2 }, 'gpt-5.2-codex-xhigh': { name: 'gpt-5.2-codex-xhigh', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_XHIGH', credit: 3 }, 'gpt-5.2-codex-low-fast': { name: 'gpt-5.2-codex-low-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_LOW_PRIORITY', credit: 2 }, 'gpt-5.2-codex-medium-fast': { name: 'gpt-5.2-codex-medium-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_MEDIUM_PRIORITY', credit: 2 }, 'gpt-5.2-codex-high-fast': { name: 'gpt-5.2-codex-high-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_HIGH_PRIORITY', credit: 4 }, 'gpt-5.2-codex-xhigh-fast': { name: 'gpt-5.2-codex-xhigh-fast', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_5_2_CODEX_XHIGH_PRIORITY', credit: 6 }, // GPT-5.3 Codex (legacy key) 'gpt-5.3-codex': { name: 'gpt-5.3-codex', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-medium', credit: 2 }, // GPT-5.4 'gpt-5.4-none': { name: 'gpt-5.4-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-none', credit: 1.5 }, 'gpt-5.4-low': { name: 'gpt-5.4-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-low', credit: 1.5 }, 'gpt-5.4-medium': { name: 'gpt-5.4-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-medium', credit: 3 }, 'gpt-5.4-high': { name: 'gpt-5.4-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-high', credit: 4 }, 'gpt-5.4-xhigh': { name: 'gpt-5.4-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-xhigh', credit: 12 }, 'gpt-5.4-mini-low': { name: 'gpt-5.4-mini-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-mini-low', credit: 1.5 }, 'gpt-5.4-mini-medium': { name: 'gpt-5.4-mini-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-mini-medium', credit: 1.5 }, 'gpt-5.4-mini-high': { name: 'gpt-5.4-mini-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-mini-high', credit: 4.5 }, 'gpt-5.4-mini-xhigh': { name: 'gpt-5.4-mini-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-4-mini-xhigh', credit: 12 }, // GPT-5.5 — Windsurf catalog 2026-04-30. Same effort ladder as 5.2/5.4 (none/low/medium/high/xhigh) // with priority (=fast) lane equivalents. Bare `gpt-5.5` defaults to medium. 'gpt-5.5': { name: 'gpt-5.5', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-medium', credit: 15 }, 'gpt-5.5-none': { name: 'gpt-5.5-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-none', credit: 9 }, 'gpt-5.5-low': { name: 'gpt-5.5-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-low', credit: 12 }, 'gpt-5.5-medium': { name: 'gpt-5.5-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-medium', credit: 15 }, 'gpt-5.5-high': { name: 'gpt-5.5-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-high', credit: 24 }, 'gpt-5.5-xhigh': { name: 'gpt-5.5-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-xhigh', credit: 36 }, 'gpt-5.5-none-fast': { name: 'gpt-5.5-none-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-none-priority', credit: 22.5 }, 'gpt-5.5-low-fast': { name: 'gpt-5.5-low-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-low-priority', credit: 30 }, 'gpt-5.5-medium-fast': { name: 'gpt-5.5-medium-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-medium-priority', credit: 37.5 }, 'gpt-5.5-high-fast': { name: 'gpt-5.5-high-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-high-priority', credit: 60 }, 'gpt-5.5-xhigh-fast': { name: 'gpt-5.5-xhigh-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-5-xhigh-priority', credit: 90 }, // GPT-5.6 — Devin catalog 2026-07-09 (Sol/Terra/Luna). Luna is the fastest // cheapest sibling; issue #244 asked for it by name. Five effort tiers // (none/low/medium/high/xhigh), no max tier in the official price table. // Bare `gpt-5.6-luna` / `gpt-5-6-luna` / compact `gpt5.6-luna` default to // medium (see _lookup aliases below). Credit from official modelCostData. 'gpt-5.6-luna': { name: 'gpt-5.6-luna', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-medium', credit: 5 }, 'gpt-5.6-luna-none': { name: 'gpt-5.6-luna-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-none', credit: 3 }, 'gpt-5.6-luna-low': { name: 'gpt-5.6-luna-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-low', credit: 4 }, 'gpt-5.6-luna-medium': { name: 'gpt-5.6-luna-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-medium', credit: 5 }, 'gpt-5.6-luna-high': { name: 'gpt-5.6-luna-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-high', credit: 6 }, 'gpt-5.6-luna-xhigh': { name: 'gpt-5.6-luna-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-xhigh', credit: 8 }, // GPT-5.3 Codex — already had bare `gpt-5.3-codex` (legacy alias), now expose tier variants. 'gpt-5.3-codex-low': { name: 'gpt-5.3-codex-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-low', credit: 1.5 }, 'gpt-5.3-codex-high': { name: 'gpt-5.3-codex-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-high', credit: 2.5 }, 'gpt-5.3-codex-xhigh': { name: 'gpt-5.3-codex-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-xhigh', credit: 3 }, 'gpt-5.3-codex-low-fast': { name: 'gpt-5.3-codex-low-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-low-priority', credit: 3 }, 'gpt-5.3-codex-medium-fast': { name: 'gpt-5.3-codex-medium-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-medium-priority', credit: 4 }, 'gpt-5.3-codex-high-fast': { name: 'gpt-5.3-codex-high-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-high-priority', credit: 5 }, 'gpt-5.3-codex-xhigh-fast': { name: 'gpt-5.3-codex-xhigh-fast', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-3-codex-xhigh-priority', credit: 6 }, // GPT-OSS 'gpt-oss-120b': { name: 'gpt-oss-120b', provider: 'openai', enumValue: 0, modelUid: 'MODEL_GPT_OSS_120B', credit: 0.25 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // GPT-5.6 Luna (max + priority lanes) 'gpt-5-6-luna-max': { name: 'gpt-5-6-luna-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-luna-max', credit: 30 }, // GPT-5.6 Sol 'gpt-5-6-sol-none': { name: 'gpt-5-6-sol-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-sol-none', credit: 10 }, 'gpt-5-6-sol-low': { name: 'gpt-5-6-sol-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-sol-low', credit: 30 }, 'gpt-5-6-sol-medium': { name: 'gpt-5-6-sol-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-sol-medium', credit: 50 }, 'gpt-5-6-sol-high': { name: 'gpt-5-6-sol-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-sol-high', credit: 90 }, 'gpt-5-6-sol-xhigh': { name: 'gpt-5-6-sol-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-sol-xhigh', credit: 150 }, 'gpt-5-6-sol-max': { name: 'gpt-5-6-sol-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-sol-max', credit: 200 }, // GPT-5.6 Terra 'gpt-5-6-terra-none': { name: 'gpt-5-6-terra-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-terra-none', credit: 4 }, 'gpt-5-6-terra-low': { name: 'gpt-5-6-terra-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-terra-low', credit: 8 }, 'gpt-5-6-terra-medium': { name: 'gpt-5-6-terra-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-terra-medium', credit: 10 }, 'gpt-5-6-terra-high': { name: 'gpt-5-6-terra-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-terra-high', credit: 20 }, 'gpt-5-6-terra-xhigh': { name: 'gpt-5-6-terra-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-terra-xhigh', credit: 30 }, 'gpt-5-6-terra-max': { name: 'gpt-5-6-terra-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-5-6-terra-max', credit: 50 }, // GPT-6.1 Sol 'gpt-6-1-sol-low': { name: 'gpt-6-1-sol-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-1-sol-low', credit: 30 }, 'gpt-6-1-sol-medium': { name: 'gpt-6-1-sol-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-1-sol-medium', credit: 40 }, 'gpt-6-1-sol-high': { name: 'gpt-6-1-sol-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-1-sol-high', credit: 50 }, 'gpt-6-1-sol-xhigh': { name: 'gpt-6-1-sol-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-1-sol-xhigh', credit: 70 }, 'gpt-6-1-sol-max': { name: 'gpt-6-1-sol-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-1-sol-max', credit: 100 }, // GPT-6 Astra 'gpt-6-astra-low': { name: 'gpt-6-astra-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-astra-low', credit: 100 }, 'gpt-6-astra-medium': { name: 'gpt-6-astra-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-astra-medium', credit: 200 }, 'gpt-6-astra-high': { name: 'gpt-6-astra-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-astra-high', credit: 400 }, 'gpt-6-astra-xhigh': { name: 'gpt-6-astra-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-astra-xhigh', credit: 375 }, 'gpt-6-astra-max': { name: 'gpt-6-astra-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-astra-max', credit: 500 }, // GPT-6 Luna 'gpt-6-luna-none': { name: 'gpt-6-luna-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-luna-none', credit: 2 }, 'gpt-6-luna-low': { name: 'gpt-6-luna-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-luna-low', credit: 3 }, 'gpt-6-luna-medium': { name: 'gpt-6-luna-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-luna-medium', credit: 4 }, 'gpt-6-luna-high': { name: 'gpt-6-luna-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-luna-high', credit: 5 }, 'gpt-6-luna-xhigh': { name: 'gpt-6-luna-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-luna-xhigh', credit: 6 }, 'gpt-6-luna-max': { name: 'gpt-6-luna-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-luna-max', credit: 7 }, // GPT-6 Sol 'gpt-6-sol-none': { name: 'gpt-6-sol-none', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-sol-none', credit: 65 }, 'gpt-6-sol-low': { name: 'gpt-6-sol-low', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-sol-low', credit: 135 }, 'gpt-6-sol-medium': { name: 'gpt-6-sol-medium', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-sol-medium', credit: 145 }, 'gpt-6-sol-high': { name: 'gpt-6-sol-high', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-sol-high', credit: 150 }, 'gpt-6-sol-xhigh': { name: 'gpt-6-sol-xhigh', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-sol-xhigh', credit: 190 }, 'gpt-6-sol-max': { name: 'gpt-6-sol-max', provider: 'openai', enumValue: 0, modelUid: 'gpt-6-sol-max', credit: 200 }, // ── O-series ──────────────────────────────────────────── 'o3-mini': { name: 'o3-mini', provider: 'openai', enumValue: 207, credit: 0.5 , deprecated: true }, 'o3': { name: 'o3', provider: 'openai', enumValue: 218, modelUid: 'MODEL_CHAT_O3', credit: 1 , deprecated: true }, 'o3-high': { name: 'o3-high', provider: 'openai', enumValue: 0, modelUid: 'MODEL_CHAT_O3_HIGH', credit: 1 , deprecated: true }, 'o3-pro': { name: 'o3-pro', provider: 'openai', enumValue: 294, credit: 4 , deprecated: true }, 'o4-mini': { name: 'o4-mini', provider: 'openai', enumValue: 264, credit: 0.5 , deprecated: true }, // ── Astraflow (UCloud) ───────────────────────────────────── // Astraflow is an OpenAI-compatible aggregation platform supporting 200+ models. // Global endpoint: https://api-us-ca.umodelverse.ai/v1 (ASTRAFLOW_API_KEY) // China endpoint: https://api.modelverse.cn/v1 (ASTRAFLOW_CN_API_KEY) // Website: https://astraflow.ucloud-global.com (global) / https://astraflow.ucloud.cn (CN) // These entries use provider:'astraflow' and set enumValue:0 / modelUid equal to the // upstream model ID so the passthrough layer can forward them to the Astraflow base URL. 'astraflow/gpt-4o': { name: 'astraflow/gpt-4o', provider: 'astraflow', enumValue: 0, modelUid: 'gpt-4o', credit: 1 , deprecated: true }, 'astraflow/gpt-4.1': { name: 'astraflow/gpt-4.1', provider: 'astraflow', enumValue: 0, modelUid: 'gpt-4.1', credit: 1 , deprecated: true }, 'astraflow/gpt-4o-mini': { name: 'astraflow/gpt-4o-mini', provider: 'astraflow', enumValue: 0, modelUid: 'gpt-4o-mini', credit: 0.5 , deprecated: true }, 'astraflow/claude-3.5-sonnet': { name: 'astraflow/claude-3.5-sonnet', provider: 'astraflow', enumValue: 0, modelUid: 'claude-3-5-sonnet-20241022', credit: 2 , deprecated: true }, 'astraflow/claude-3.7-sonnet': { name: 'astraflow/claude-3.7-sonnet', provider: 'astraflow', enumValue: 0, modelUid: 'claude-3-7-sonnet-20250219', credit: 2 , deprecated: true }, 'astraflow/deepseek-v3': { name: 'astraflow/deepseek-v3', provider: 'astraflow', enumValue: 0, modelUid: 'deepseek-v3', credit: 1 , deprecated: true }, 'astraflow/deepseek-r1': { name: 'astraflow/deepseek-r1', provider: 'astraflow', enumValue: 0, modelUid: 'deepseek-r1', credit: 2 , deprecated: true }, 'astraflow/llama-3.3-70b': { name: 'astraflow/llama-3.3-70b', provider: 'astraflow', enumValue: 0, modelUid: 'llama-3.3-70b-instruct', credit: 0.5 , deprecated: true }, 'astraflow/gemini-2.0-flash': { name: 'astraflow/gemini-2.0-flash', provider: 'astraflow', enumValue: 0, modelUid: 'gemini-2.0-flash', credit: 0.5 , deprecated: true }, // ── Gemini ────────────────────────────────────────────── 'gemini-2.5-pro': { name: 'gemini-2.5-pro', provider: 'google', enumValue: 246, modelUid: 'MODEL_GOOGLE_GEMINI_2_5_PRO', credit: 1 }, 'gemini-2.5-flash': { name: 'gemini-2.5-flash', provider: 'google', enumValue: 312, modelUid: 'MODEL_GOOGLE_GEMINI_2_5_FLASH', credit: 0.5 }, 'gemini-3.0-pro': { name: 'gemini-3.0-pro', provider: 'google', enumValue: 412, modelUid: 'MODEL_GOOGLE_GEMINI_3_0_PRO_LOW', credit: 1 }, 'gemini-3.0-flash-minimal': { name: 'gemini-3.0-flash-minimal', provider: 'google', enumValue: 0, modelUid: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL', credit: 0.75 }, 'gemini-3.0-flash-low': { name: 'gemini-3.0-flash-low', provider: 'google', enumValue: 0, modelUid: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW', credit: 1 }, 'gemini-3.0-flash': { name: 'gemini-3.0-flash', provider: 'google', enumValue: 415, modelUid: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM', credit: 1 }, 'gemini-3.0-flash-high': { name: 'gemini-3.0-flash-high', provider: 'google', enumValue: 0, modelUid: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH', credit: 1.75 }, 'gemini-3.1-pro-low': { name: 'gemini-3.1-pro-low', provider: 'google', enumValue: 0, modelUid: 'gemini-3-1-pro-low', credit: 1 }, 'gemini-3.1-pro-high': { name: 'gemini-3.1-pro-high', provider: 'google', enumValue: 0, modelUid: 'gemini-3-1-pro-high', credit: 2 }, // Gemini 3.5 Flash — live GetCascadeModelConfigs 2026-10-18. 'gemini-3-5-flash-minimal': { name: 'gemini-3-5-flash-minimal', provider: 'google', enumValue: 0, modelUid: 'gemini-3-5-flash-minimal', credit: 2 }, 'gemini-3-5-flash-low': { name: 'gemini-3-5-flash-low', provider: 'google', enumValue: 0, modelUid: 'gemini-3-5-flash-low', credit: 4 }, 'gemini-3-5-flash-medium': { name: 'gemini-3-5-flash-medium', provider: 'google', enumValue: 0, modelUid: 'gemini-3-5-flash-medium', credit: 8 }, 'gemini-3-5-flash-high': { name: 'gemini-3-5-flash-high', provider: 'google', enumValue: 0, modelUid: 'gemini-3-5-flash-high', credit: 9 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // Gemini 3.6 Flash 'gemini-3-6-flash-minimal': { name: 'gemini-3-6-flash-minimal', provider: 'google', enumValue: 0, modelUid: 'gemini-3-6-flash-minimal', credit: 2 }, 'gemini-3-6-flash-low': { name: 'gemini-3-6-flash-low', provider: 'google', enumValue: 0, modelUid: 'gemini-3-6-flash-low', credit: 4 }, 'gemini-3-6-flash-medium': { name: 'gemini-3-6-flash-medium', provider: 'google', enumValue: 0, modelUid: 'gemini-3-6-flash-medium', credit: 8 }, 'gemini-3-6-flash-high': { name: 'gemini-3-6-flash-high', provider: 'google', enumValue: 0, modelUid: 'gemini-3-6-flash-high', credit: 9 }, // Gemini 3.7 Flash 'gemini-3-7-flash-low': { name: 'gemini-3-7-flash-low', provider: 'google', enumValue: 0, modelUid: 'gemini-3-7-flash-low', credit: 6 }, 'gemini-3-7-flash-medium': { name: 'gemini-3-7-flash-medium', provider: 'google', enumValue: 0, modelUid: 'gemini-3-7-flash-medium', credit: 8 }, 'gemini-3-7-flash-high': { name: 'gemini-3-7-flash-high', provider: 'google', enumValue: 0, modelUid: 'gemini-3-7-flash-high', credit: 10 }, // Gemini 3.8 Flash 'gemini-3-8-flash-low': { name: 'gemini-3-8-flash-low', provider: 'google', enumValue: 0, modelUid: 'gemini-3-8-flash-low', credit: 6 }, 'gemini-3-8-flash-medium': { name: 'gemini-3-8-flash-medium', provider: 'google', enumValue: 0, modelUid: 'gemini-3-8-flash-medium', credit: 8 }, 'gemini-3-8-flash-high': { name: 'gemini-3-8-flash-high', provider: 'google', enumValue: 0, modelUid: 'gemini-3-8-flash-high', credit: 10 }, // Gemini 4 Argon 'gemini-4-argon-low': { name: 'gemini-4-argon-low', provider: 'google', enumValue: 0, modelUid: 'gemini-4-argon-low', credit: 50 }, 'gemini-4-argon-medium': { name: 'gemini-4-argon-medium', provider: 'google', enumValue: 0, modelUid: 'gemini-4-argon-medium', credit: 70 }, 'gemini-4-argon-high': { name: 'gemini-4-argon-high', provider: 'google', enumValue: 0, modelUid: 'gemini-4-argon-high', credit: 90 }, // ── DeepSeek ──────────────────────────────────────────── 'deepseek-v3': { name: 'deepseek-v3', provider: 'deepseek', enumValue: 205, credit: 0.5, deprecated: true }, 'deepseek-v3-2': { name: 'deepseek-v3-2', provider: 'deepseek', enumValue: 409, credit: 0.5, deprecated: true }, 'deepseek-r1': { name: 'deepseek-r1', provider: 'deepseek', enumValue: 206, credit: 1, deprecated: true }, // DeepSeek V4 Pro — live in the 105 catalog (2026-07). DEVIN_CONNECT selector. 'deepseek-v4': { name: 'deepseek-v4', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4', credit: 0.5 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // DeepSeek V4 Flash 'deepseek-v4-flash-low': { name: 'deepseek-v4-flash-low', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-flash-low', credit: 0.25 }, 'deepseek-v4-flash-high': { name: 'deepseek-v4-flash-high', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-flash-high', credit: 0.5 }, 'deepseek-v4-flash-max': { name: 'deepseek-v4-flash-max', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-flash-max', credit: 1 }, // DeepSeek V4 Pro 'deepseek-v4-pro-low': { name: 'deepseek-v4-pro-low', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-pro-low', credit: 3 }, 'deepseek-v4-pro-high': { name: 'deepseek-v4-pro-high', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-pro-high', credit: 3 }, 'deepseek-v4-pro-max': { name: 'deepseek-v4-pro-max', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-pro-max', credit: 3 }, // DeepSeek V4.1 Flash 'deepseek-v4-1-flash-high': { name: 'deepseek-v4-1-flash-high', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-1-flash-high', credit: 2 }, 'deepseek-v4-1-flash-max': { name: 'deepseek-v4-1-flash-max', provider: 'deepseek', enumValue: 0, modelUid: 'deepseek-v4-1-flash-max', credit: 3 }, // ── Grok ──────────────────────────────────────────────── 'grok-3': { name: 'grok-3', provider: 'xai', enumValue: 217, modelUid: 'MODEL_XAI_GROK_3', credit: 1 }, 'grok-3-mini': { name: 'grok-3-mini', provider: 'xai', enumValue: 234, credit: 0.5, deprecated: true }, 'grok-3-mini-thinking': { name: 'grok-3-mini-thinking', provider: 'xai', enumValue: 0, modelUid: 'MODEL_XAI_GROK_3_MINI_REASONING', credit: 0.125 }, 'grok-code-fast-1': { name: 'grok-code-fast-1', provider: 'xai', enumValue: 0, modelUid: 'MODEL_PRIVATE_4', credit: 0.5 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // Grok 4.5 'grok-4-5-low': { name: 'grok-4-5-low', provider: 'xai', enumValue: 0, modelUid: 'grok-4-5-low', credit: 5 }, 'grok-4-5-medium': { name: 'grok-4-5-medium', provider: 'xai', enumValue: 0, modelUid: 'grok-4-5-medium', credit: 8 }, 'grok-4-5-high': { name: 'grok-4-5-high', provider: 'xai', enumValue: 0, modelUid: 'grok-4-5-high', credit: 10 }, // Grok 4.6 'grok-4-6-low': { name: 'grok-4-6-low', provider: 'xai', enumValue: 0, modelUid: 'grok-4-6-low', credit: 10 }, 'grok-4-6-medium': { name: 'grok-4-6-medium', provider: 'xai', enumValue: 0, modelUid: 'grok-4-6-medium', credit: 20 }, 'grok-4-6-high': { name: 'grok-4-6-high', provider: 'xai', enumValue: 0, modelUid: 'grok-4-6-high', credit: 25 }, 'grok-4-6-xhigh': { name: 'grok-4-6-xhigh', provider: 'xai', enumValue: 0, modelUid: 'grok-4-6-xhigh', credit: 30 }, // Grok 4.7 'grok-4-7-low': { name: 'grok-4-7-low', provider: 'xai', enumValue: 0, modelUid: 'grok-4-7-low', credit: 15 }, 'grok-4-7-medium': { name: 'grok-4-7-medium', provider: 'xai', enumValue: 0, modelUid: 'grok-4-7-medium', credit: 20 }, 'grok-4-7-high': { name: 'grok-4-7-high', provider: 'xai', enumValue: 0, modelUid: 'grok-4-7-high', credit: 30 }, 'grok-4-7-xhigh': { name: 'grok-4-7-xhigh', provider: 'xai', enumValue: 0, modelUid: 'grok-4-7-xhigh', credit: 55 }, // ── Qwen ──────────────────────────────────────────────── 'qwen-3': { name: 'qwen-3', provider: 'alibaba', enumValue: 324, credit: 0.5, deprecated: true }, // qwen-3-coder + qwen-3-coder-fast: exist in binary enum (325/327) // but cascade server doesn't have any routing registered for them — // both enum-only and explicit UIDs fail with 'model not found'. // Removed from catalog until upstream registers them. // ── Kimi ──────────────────────────────────────────────── 'kimi-k2': { name: 'kimi-k2', provider: 'moonshot', enumValue: 323, modelUid: 'MODEL_KIMI_K2', credit: 0.5 }, 'kimi-k2-thinking': { name: 'kimi-k2-thinking', provider: 'moonshot', enumValue: 394, modelUid: 'MODEL_KIMI_K2_THINKING', credit: 1 }, 'kimi-k2.5': { name: 'kimi-k2.5', provider: 'moonshot', enumValue: 0, modelUid: 'kimi-k2-5', credit: 1 }, 'kimi-k2-6': { name: 'kimi-k2-6', provider: 'moonshot', enumValue: 0, modelUid: 'kimi-k2-6', credit: 1 }, 'kimi-k2-7': { name: 'kimi-k2-7', provider: 'moonshot', enumValue: 0, modelUid: 'kimi-k2-7', credit: 1 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // Kimi K3 'kimi-k3-low': { name: 'kimi-k3-low', provider: 'moonshot', enumValue: 0, modelUid: 'kimi-k3-low', credit: 4 }, 'kimi-k3-high': { name: 'kimi-k3-high', provider: 'moonshot', enumValue: 0, modelUid: 'kimi-k3-high', credit: 6 }, 'kimi-k3-max': { name: 'kimi-k3-max', provider: 'moonshot', enumValue: 0, modelUid: 'kimi-k3-max', credit: 9 }, // ── GLM ───────────────────────────────────────────────── 'glm-4.7': { name: 'glm-4.7', provider: 'zhipu', enumValue: 417, modelUid: 'MODEL_GLM_4_7', credit: 0.25 }, 'glm-4.7-fast': { name: 'glm-4.7-fast', provider: 'zhipu', enumValue: 418, modelUid: 'MODEL_GLM_4_7_FAST', credit: 0.5 }, 'glm-5': { name: 'glm-5', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5', credit: 1.5 }, 'glm-5.1': { name: 'glm-5.1', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-1', credit: 1.5 }, 'glm-5.2': { name: 'glm-5.2', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-2', credit: 1.5 }, // GLM-5.2 variants — live GetCascadeModelConfigs 2026-10-18. 'glm-5-2-none': { name: 'glm-5-2-none', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-2-none', credit: 1 }, 'glm-5-2-none-1m': { name: 'glm-5-2-none-1m', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-2-none-1m', credit: 2 }, 'glm-5-2-1m': { name: 'glm-5-2-1m', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-2-1m', credit: 3 }, 'glm-5-2-max': { name: 'glm-5-2-max', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-2-max', credit: 3 }, 'glm-5-2-max-1m': { name: 'glm-5-2-max-1m', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-2-max-1m', credit: 6 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // GLM-5.3 Flash 'glm-5-3-flash-low': { name: 'glm-5-3-flash-low', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-3-flash-low', credit: 1 }, 'glm-5-3-flash-high': { name: 'glm-5-3-flash-high', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-3-flash-high', credit: 1 }, 'glm-5-3-flash-max': { name: 'glm-5-3-flash-max', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-3-flash-max', credit: 1 }, // GLM-5.3 'glm-5-3-low': { name: 'glm-5-3-low', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-3-low', credit: 1 }, 'glm-5-3-high': { name: 'glm-5-3-high', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-3-high', credit: 1 }, 'glm-5-3-max': { name: 'glm-5-3-max', provider: 'zhipu', enumValue: 0, modelUid: 'glm-5-3-max', credit: 1 }, // ── MiniMax ───────────────────────────────────────────── // proto enum 419 = MODEL_MINIMAX_M2_1; the canonical name in cloud configs is m2.5. 'minimax-m2.5': { name: 'minimax-m2.5', provider: 'minimax', enumValue: 419, modelUid: 'MODEL_MINIMAX_M2_1', credit: 1 }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // Inkling (Thinking Machines) 'inkling-none': { name: 'inkling-none', provider: 'thinking_machines', enumValue: 0, modelUid: 'inkling-none', credit: 2 }, 'inkling-low': { name: 'inkling-low', provider: 'thinking_machines', enumValue: 0, modelUid: 'inkling-low', credit: 4 }, 'inkling-medium': { name: 'inkling-medium', provider: 'thinking_machines', enumValue: 0, modelUid: 'inkling-medium', credit: 6 }, 'inkling-high': { name: 'inkling-high', provider: 'thinking_machines', enumValue: 0, modelUid: 'inkling-high', credit: 8 }, 'inkling-xhigh': { name: 'inkling-xhigh', provider: 'thinking_machines', enumValue: 0, modelUid: 'inkling-xhigh', credit: 10 }, 'inkling-max': { name: 'inkling-max', provider: 'thinking_machines', enumValue: 0, modelUid: 'inkling-max', credit: 14 }, // Nemotron 3 Ultra (NVIDIA) 'nemotron-3-ultra-none': { name: 'nemotron-3-ultra-none', provider: 'nvidia', enumValue: 0, modelUid: 'nemotron-3-ultra-none', credit: 1 }, 'nemotron-3-ultra-medium': { name: 'nemotron-3-ultra-medium', provider: 'nvidia', enumValue: 0, modelUid: 'nemotron-3-ultra-medium', credit: 1 }, 'nemotron-3-ultra-high': { name: 'nemotron-3-ultra-high', provider: 'nvidia', enumValue: 0, modelUid: 'nemotron-3-ultra-high', credit: 1 }, // ── Windsurf SWE ──────────────────────────────────────── // Proto canonical enums: 359=MODEL_SWE_1_5 (fast), 369=THINKING, 377=SLOW, 420=1_6, 421=1_6_FAST. // The default `swe-1.5` UID alias in upstream cloud config maps to the SLOW tier (377). 'swe-1.5': { name: 'swe-1.5', provider: 'windsurf', enumValue: 377, modelUid: 'MODEL_SWE_1_5_SLOW', credit: 0.5 }, 'swe-1.5-fast': { name: 'swe-1.5-fast', provider: 'windsurf', enumValue: 359, modelUid: 'MODEL_SWE_1_5', credit: 0.5 }, 'swe-1.5-thinking': { name: 'swe-1.5-thinking', provider: 'windsurf', enumValue: 369, modelUid: 'MODEL_SWE_1_5_THINKING', credit: 0.75 }, 'swe-1.6': { name: 'swe-1.6', provider: 'windsurf', enumValue: 420, modelUid: 'MODEL_SWE_1_6', credit: 3, backend: 'special_agent' }, 'swe-1.6-fast': { name: 'swe-1.6-fast', provider: 'windsurf', enumValue: 421, modelUid: 'MODEL_SWE_1_6_FAST', credit: 3, backend: 'special_agent' }, // SWE-1.7 family — live in the 105 catalog (2026-07). DEVIN_CONNECT selectors // (dash-form modelUid, no special_agent backend — resolved via catalog, not LS). 'swe-1-7': { name: 'swe-1-7', provider: 'windsurf', enumValue: 0, modelUid: 'swe-1-7', credit: 9 }, 'swe-1-7-lightning': { name: 'swe-1-7-lightning', provider: 'windsurf', enumValue: 0, modelUid: 'swe-1-7-lightning', credit: 18 }, // SWE-2 — Cognition 2026-09-10 (docs.devin.ai: swe-2-medium/high/max). // DEVIN_CONNECT=1 uses the snapshot selectors. Cascade has no UID, so the // LS path marks them special_agent (same as swe-1.6) for an opted-in CLI. 'swe-2': { name: 'swe-2', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-medium', credit: 6, backend: 'special_agent' }, 'swe-2-medium': { name: 'swe-2-medium', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-medium', credit: 6, backend: 'special_agent' }, 'swe-2-high': { name: 'swe-2-high', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-high', credit: 9, backend: 'special_agent' }, 'swe-2-max': { name: 'swe-2-max', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-max', credit: 12, backend: 'special_agent' }, // SWE-2 Lightning — live GetCascadeModelConfigs 2026-10-18. 'swe-2-medium-lightning': { name: 'swe-2-medium-lightning', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-medium-lightning', credit: 12, backend: 'special_agent' }, 'swe-2-high-lightning': { name: 'swe-2-high-lightning', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-high-lightning', credit: 18, backend: 'special_agent' }, 'swe-2-max-lightning': { name: 'swe-2-max-lightning', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-max-lightning', credit: 24, backend: 'special_agent' }, // ── added 2026-10-16 from docs.devin.ai modelCostData (public price table); NOT yet frame-verified ── // SWE-1.7 Lightning 'swe-1-7-lightning-medium': { name: 'swe-1-7-lightning-medium', provider: 'windsurf', enumValue: 0, modelUid: 'swe-1-7-lightning-medium', credit: 9 }, // SWE-1.7 'swe-1-7-medium': { name: 'swe-1-7-medium', provider: 'windsurf', enumValue: 0, modelUid: 'swe-1-7-medium', credit: 3 }, // ── Adaptive (Windsurf 2026-04-06 changelog) ──────────── // Adaptive Model Router + Arena models live in the cloud catalog but their // UIDs aren't recognized by SendUserCascadeMessage's direct-call path — // upstream returns "unknown model UID adaptive: model not found". They only // work through the Windsurf IDE's special routing layer that Cascade-direct // doesn't expose. Keep them hidden from /v1/models by default, but route // explicit calls through the optional special-agent backend instead of the // broken direct Cascade path. #109/#190. 'adaptive': { name: 'adaptive', provider: 'windsurf', enumValue: 0, modelUid: 'adaptive', credit: 1.5, deprecated: true, backend: 'special_agent' }, 'arena-fast': { name: 'arena-fast', provider: 'windsurf', enumValue: 0, modelUid: 'arena-fast', credit: 1, deprecated: true, backend: 'special_agent' }, 'arena-smart': { name: 'arena-smart', provider: 'windsurf', enumValue: 0, modelUid: 'arena-smart', credit: 2, deprecated: true, backend: 'special_agent' }, }; // Build reverse lookup const _lookup = new Map(); for (const [id, info] of Object.entries(MODELS)) { _lookup.set(id, id); _lookup.set(id.toLowerCase(), id); _lookup.set(info.name, id); _lookup.set(info.name.toLowerCase(), id); // modelUid can be a provider-local upstream id. Astraflow entries, for // example, use modelUid="gpt-4o"; that must not steal the public // "gpt-4o" alias from the native Windsurf model. if (info.modelUid && !_lookup.has(info.modelUid)) _lookup.set(info.modelUid, id); if (info.modelUid) { const lowerUid = info.modelUid.toLowerCase(); if (!_lookup.has(lowerUid)) _lookup.set(lowerUid, id); } } // Legacy aliases _lookup.set('claude-sonnet-4-6-thinking', 'claude-sonnet-4.6-thinking'); _lookup.set('claude-opus-4-6-thinking', 'claude-opus-4.6-thinking'); _lookup.set('claude-sonnet-4-6', 'claude-sonnet-4.6'); _lookup.set('claude-opus-4-6', 'claude-opus-4.6'); _lookup.set('MODEL_CLAUDE_4_5_SONNET', 'claude-4.5-sonnet'); _lookup.set('MODEL_CLAUDE_4_5_SONNET_THINKING', 'claude-4.5-sonnet-thinking'); // UID-based aliases not already covered by modelUid field _lookup.set('claude-sonnet-4-6-1m', 'claude-sonnet-4.6-1m'); _lookup.set('claude-sonnet-4-6-thinking-1m', 'claude-sonnet-4.6-thinking-1m'); // Bare `claude-4.6` (no explicit sonnet/opus) — issue #68. Without these, // resolveModel falls through to the raw string, getModelInfo returns null, // and chat.js silently routes to legacy rawGetChatMessage with no model // name, so the upstream falls back to a default model whose self-knowledge // is "I'm Claude 4.5". Default the bare alias to sonnet (more common). _lookup.set('claude-4.6', 'claude-sonnet-4.6'); _lookup.set('claude-4.6-thinking', 'claude-sonnet-4.6-thinking'); _lookup.set('claude-4.6-1m', 'claude-sonnet-4.6-1m'); _lookup.set('claude-4.6-thinking-1m', 'claude-sonnet-4.6-thinking-1m'); // Bare `claude5` / `claude-5` — issue #244 verbatim. Default to sonnet-5 medium // (same convention as claude-4.6 → sonnet: sonnet is the more common tier). _lookup.set('claude5', 'claude-sonnet-5-medium'); _lookup.set('claude-5', 'claude-sonnet-5-medium'); _lookup.set('gpt-5-4-none', 'gpt-5.4-none'); _lookup.set('gpt-5-4-low', 'gpt-5.4-low'); _lookup.set('gpt-5-4-medium', 'gpt-5.4-medium'); _lookup.set('gpt-5-4-high', 'gpt-5.4-high'); _lookup.set('gpt-5-4-xhigh', 'gpt-5.4-xhigh'); _lookup.set('gpt-5-4-mini-low', 'gpt-5.4-mini-low'); _lookup.set('gpt-5-4-mini-medium', 'gpt-5.4-mini-medium'); _lookup.set('gpt-5-4-mini-high', 'gpt-5.4-mini-high'); _lookup.set('gpt-5-4-mini-xhigh', 'gpt-5.4-mini-xhigh'); // Bare-tier aliases — clients commonly write the dotted form for the medium tier // even when the catalog uses bare-only or tier-only entries. Without these the // /v1/messages handler 400s "Unsupported model" before forwarding. #109 sub2api // reproducer was `gpt-5.2-medium` (bare gpt-5.2 = medium but the alias was missing). _lookup.set('gpt-5.2-medium', 'gpt-5.2'); // bare gpt-5.2 IS the medium tier _lookup.set('gpt-5-2-medium', 'gpt-5.2'); // cloud-format equivalent _lookup.set('gpt-5.2-codex', 'gpt-5.2-codex-medium'); // bare codex → medium _lookup.set('gpt-5-2-codex-medium', 'gpt-5.2-codex-medium'); _lookup.set('gpt-5.3-codex-medium', 'gpt-5.3-codex'); // bare codex IS medium _lookup.set('gpt-5.4', 'gpt-5.4-medium'); // bare → medium per family convention // gpt-5.5 cloud-format aliases (cloud sends `gpt-5-5-*`, OpenAI-style is `gpt-5.5-*`) _lookup.set('gpt-5-5', 'gpt-5.5'); _lookup.set('gpt-5-5-none', 'gpt-5.5-none'); _lookup.set('gpt-5-5-low', 'gpt-5.5-low'); _lookup.set('gpt-5-5-medium', 'gpt-5.5-medium'); _lookup.set('gpt-5-5-high', 'gpt-5.5-high'); _lookup.set('gpt-5-5-xhigh', 'gpt-5.5-xhigh'); _lookup.set('gpt-5-5-none-priority', 'gpt-5.5-none-fast'); _lookup.set('gpt-5-5-low-priority', 'gpt-5.5-low-fast'); _lookup.set('gpt-5-5-medium-priority', 'gpt-5.5-medium-fast'); _lookup.set('gpt-5-5-high-priority', 'gpt-5.5-high-fast'); _lookup.set('gpt-5-5-xhigh-priority', 'gpt-5.5-xhigh-fast'); // gpt-5.6-luna cloud-format aliases (cloud sends `gpt-5-6-luna-*`, OpenAI-style // is `gpt-5.6-luna-*`). Bare + compact `gpt5.6-luna` (issue #244 verbatim) → medium. _lookup.set('gpt-5-6-luna', 'gpt-5.6-luna-medium'); _lookup.set('gpt5.6-luna', 'gpt-5.6-luna-medium'); _lookup.set('gpt-5-6-luna-none', 'gpt-5.6-luna-none'); _lookup.set('gpt-5-6-luna-low', 'gpt-5.6-luna-low'); _lookup.set('gpt-5-6-luna-medium', 'gpt-5.6-luna-medium'); _lookup.set('gpt-5-6-luna-high', 'gpt-5.6-luna-high'); _lookup.set('gpt-5-6-luna-xhigh', 'gpt-5.6-luna-xhigh'); // gpt-5.3-codex tier aliases _lookup.set('gpt-5-3-codex-low', 'gpt-5.3-codex-low'); _lookup.set('gpt-5-3-codex-medium', 'gpt-5.3-codex'); _lookup.set('gpt-5-3-codex-high', 'gpt-5.3-codex-high'); _lookup.set('gpt-5-3-codex-xhigh', 'gpt-5.3-codex-xhigh'); _lookup.set('gpt-5-3-codex-low-priority', 'gpt-5.3-codex-low-fast'); _lookup.set('gpt-5-3-codex-medium-priority', 'gpt-5.3-codex-medium-fast'); _lookup.set('gpt-5-3-codex-high-priority', 'gpt-5.3-codex-high-fast'); _lookup.set('gpt-5-3-codex-xhigh-priority', 'gpt-5.3-codex-xhigh-fast'); // Cloud-format aliases for existing dotted names _lookup.set('swe-1-6', 'swe-1.6'); _lookup.set('swe-1-6-fast', 'swe-1.6-fast'); _lookup.set('swe-2.0', 'swe-2'); _lookup.set('swe-2-0', 'swe-2'); _lookup.set('swe2', 'swe-2'); _lookup.set('swe-2.0-medium', 'swe-2-medium'); _lookup.set('swe-2.0-high', 'swe-2-high'); _lookup.set('swe-2.0-max', 'swe-2-max'); _lookup.set('minimax-m2-5', 'minimax-m2.5'); _lookup.set('kimi-k2-5', 'kimi-k2.5'); _lookup.set('kimi-k2.6', 'kimi-k2-6'); _lookup.set('kimi-k2.7', 'kimi-k2-7'); _lookup.set('glm-5-1', 'glm-5.1'); _lookup.set('glm-5-2', 'glm-5.2'); // Anthropic official dated names — Cursor / Claude Code / Anthropic SDK // all send these verbatim. Map each to our short key so the same client // can talk to this API without a custom-name translation layer. const ANTHROPIC_DATED = { 'claude-3-5-sonnet-20240620': 'claude-3.5-sonnet', 'claude-3-5-sonnet-20241022': 'claude-3.5-sonnet', 'claude-3-5-sonnet-latest': 'claude-3.5-sonnet', 'claude-3-7-sonnet-20250219': 'claude-3.7-sonnet', 'claude-3-7-sonnet-latest': 'claude-3.7-sonnet', 'claude-sonnet-4-20250514': 'claude-4-sonnet', 'claude-sonnet-4-0': 'claude-4-sonnet', 'claude-opus-4-20250514': 'claude-4-opus', 'claude-opus-4-0': 'claude-4-opus', 'claude-opus-4-1': 'claude-4.1-opus', 'claude-opus-4-1-20250805': 'claude-4.1-opus', 'claude-sonnet-4-5': 'claude-4.5-sonnet', 'claude-sonnet-4-5-20250929': 'claude-4.5-sonnet', 'claude-sonnet-4-5-latest': 'claude-4.5-sonnet', 'claude-opus-4-5': 'claude-4.5-opus', 'claude-opus-4-5-20251101': 'claude-4.5-opus', 'claude-opus-4-5-latest': 'claude-4.5-opus', // Claude Haiku 4.5 — Anthropic official id `claude-haiku-4-5-20251001` // (#117 xiaoxin-zk: dashboard test sent the dated form, hit // "Unsupported model" 400 because no alias existed). Cover the dated // name + bare + latest the same way sonnet/opus already are. 'claude-haiku-4-5': 'claude-4.5-haiku', 'claude-haiku-4-5-20251001': 'claude-4.5-haiku', 'claude-haiku-4-5-latest': 'claude-4.5-haiku', // v2.0.85: README + every recent reply uses the dotted form // `claude-haiku-4.5` (mirrors `claude-sonnet-4.6`). Alias both // dotted and dashed so users following the docs verbatim don't hit // 400 model_not_found. 'claude-haiku-4.5': 'claude-4.5-haiku', 'claude-haiku-4.5-latest': 'claude-4.5-haiku', // Sonnet 4.5 dotted-suffix variants for the same reason. 'claude-sonnet-4.5': 'claude-4.5-sonnet', 'claude-sonnet-4.5-thinking': 'claude-4.5-sonnet-thinking', 'claude-opus-4.5': 'claude-4.5-opus', 'claude-opus-4.5-thinking': 'claude-4.5-opus-thinking', // Legacy Haiku dated names — Anthropic SDK clients sometimes still // ship these. Map to the closest live model (4.5-haiku) so the request // doesn't 400; the `deprecated` flag isn't set on 4.5-haiku so it // routes normally. 'claude-3-5-haiku-20241022': 'claude-4.5-haiku', 'claude-3-5-haiku-latest': 'claude-4.5-haiku', 'claude-haiku-3-5': 'claude-4.5-haiku', 'claude-haiku-3-5-latest': 'claude-4.5-haiku', // Anthropic Opus 4.7 — Windsurf changelog 2026-04-16. Cloud now exposes 4 reasoning // tiers (low/medium/high/xhigh) plus matching -thinking variants. Bare `claude-opus-4-7` // and `claude-opus-4.7` default to medium; `-thinking` suffix routes to medium-thinking. 'claude-opus-4-7': 'claude-opus-4-7-medium', 'claude-opus-4-7-latest': 'claude-opus-4-7-medium', 'claude-opus-4.7': 'claude-opus-4-7-medium', 'claude-opus-4.7-thinking': 'claude-opus-4-7-medium-thinking', 'claude-opus-4-7-thinking': 'claude-opus-4-7-medium-thinking', 'claude-opus-4.7-low': 'claude-opus-4-7-low', 'claude-opus-4.7-medium': 'claude-opus-4-7-medium', 'claude-opus-4.7-high': 'claude-opus-4-7-high', 'claude-opus-4.7-xhigh': 'claude-opus-4-7-xhigh', 'claude-opus-4.7-medium-thinking': 'claude-opus-4-7-medium-thinking', 'claude-opus-4.7-high-thinking': 'claude-opus-4-7-high-thinking', 'claude-opus-4.7-xhigh-thinking': 'claude-opus-4-7-xhigh-thinking', 'claude-opus-4.7-max': 'claude-opus-4-7-max', // Anthropic Opus 4.8. Bare aliases default to medium; tier aliases route explicitly. 'claude-opus-4-8': 'claude-opus-4-8-medium', 'claude-opus-4-8-latest': 'claude-opus-4-8-medium', 'claude-opus-4.8': 'claude-opus-4-8-medium', 'claude-opus-4.8-low': 'claude-opus-4-8-low', 'claude-opus-4.8-medium': 'claude-opus-4-8-medium', 'claude-opus-4.8-high': 'claude-opus-4-8-high', 'claude-opus-4.8-xhigh': 'claude-opus-4-8-xhigh', 'claude-opus-4.8-max': 'claude-opus-4-8-max', 'claude-opus-4.8-low-fast': 'claude-opus-4-8-low-fast', 'claude-opus-4.8-medium-fast': 'claude-opus-4-8-medium-fast', 'claude-opus-4.8-high-fast': 'claude-opus-4-8-high-fast', 'claude-opus-4.8-xhigh-fast': 'claude-opus-4-8-xhigh-fast', 'claude-opus-4.8-max-fast': 'claude-opus-4-8-max-fast', 'claude-opus-4-8-thinking': 'claude-opus-4-8-medium', 'claude-opus-4.8-thinking': 'claude-opus-4-8-medium', // Claude 5 — bare aliases default to medium (family convention). Upstream // selectors are the dash form (claude-5-fable-*, claude-sonnet-5-*, // claude-opus-5-*); `claude-fable-5-*` is the docs display name, not the wire // selector. No dated suffix variants exist yet — bare/latest cover clients. 'claude-5-fable': 'claude-5-fable-medium', 'claude-5-fable-latest': 'claude-5-fable-medium', 'claude-sonnet-5': 'claude-sonnet-5-medium', 'claude-sonnet-5-latest': 'claude-sonnet-5-medium', 'claude-opus-5': 'claude-opus-5-medium', 'claude-opus-5-latest': 'claude-opus-5-medium', 'claude-5-fable-low': 'claude-5-fable-low', 'claude-5-fable-high': 'claude-5-fable-high', 'claude-5-fable-xhigh': 'claude-5-fable-xhigh', 'claude-5-fable-max': 'claude-5-fable-max', 'claude-sonnet-5-low': 'claude-sonnet-5-low', 'claude-sonnet-5-high': 'claude-sonnet-5-high', 'claude-sonnet-5-xhigh': 'claude-sonnet-5-xhigh', 'claude-sonnet-5-max': 'claude-sonnet-5-max', 'claude-opus-5-low': 'claude-opus-5-low', 'claude-opus-5-high': 'claude-opus-5-high', 'claude-opus-5-xhigh': 'claude-opus-5-xhigh', 'claude-opus-5-max': 'claude-opus-5-max', }; for (const [k, v] of Object.entries(ANTHROPIC_DATED)) _lookup.set(k, v); // OpenAI official dated names — same pattern const OPENAI_DATED = { 'gpt-4o-2024-11-20': 'gpt-4o', 'gpt-4o-2024-08-06': 'gpt-4o', 'gpt-4o-2024-05-13': 'gpt-4o', 'gpt-4o-mini-2024-07-18': 'gpt-4o-mini', 'gpt-4.1-2025-04-14': 'gpt-4.1', 'gpt-4.1-mini-2025-04-14': 'gpt-4.1-mini', 'gpt-4.1-nano-2025-04-14': 'gpt-4.1-nano', 'gpt-5-2025-08-07': 'gpt-5', 'gpt-5-pro-2025-10-06': 'gpt-5-high', // GPT-5.5 — bare aliases default to medium tier (matches gpt-5.2 / gpt-5.4 pattern). 'gpt-5-5': 'gpt-5.5-medium', 'gpt-5.5': 'gpt-5.5-medium', // GPT-5.6 Luna — OpenAI-side slug `gpt-5.6-luna` (bare → medium). 'gpt-5.6-luna': 'gpt-5.6-luna-medium', }; for (const [k, v] of Object.entries(OPENAI_DATED)) _lookup.set(k, v); // Cursor-friendly aliases — Cursor's client-side whitelist blocks model names // containing "claude". These prefixes bypass the filter while resolving to the // same Windsurf backend models. Use any of these in Cursor's Custom Model field. const CURSOR_ALIASES = { // opus 'opus-4.6': 'claude-opus-4.6', 'opus-4.6-thinking': 'claude-opus-4.6-thinking', 'opus-4.7-thinking': 'claude-opus-4-7-medium-thinking', 'opus-4-7': 'claude-opus-4-7-medium', 'opus-4.7': 'claude-opus-4-7-medium', 'o4.7': 'claude-opus-4-7-medium', 'opus-4-8': 'claude-opus-4-8-medium', 'opus-4.8': 'claude-opus-4-8-medium', 'opus-4.8-thinking': 'claude-opus-4-8-medium', 'o4.8': 'claude-opus-4-8-medium', // Claude 5 — Cursor whitelist blocks "claude" in model names, so bare // family names resolve through here. 'opus-5': 'claude-opus-5-medium', 'o5': 'claude-opus-5-medium', 'fable-5': 'claude-5-fable-medium', 'sonnet-5': 'claude-sonnet-5-medium', // sonnet 'sonnet-4.6': 'claude-sonnet-4.6', 'sonnet-4.6-thinking': 'claude-sonnet-4.6-thinking', 'sonnet-4.6-1m': 'claude-sonnet-4.6-1m', 'sonnet-4.5': 'claude-4.5-sonnet', 'sonnet-4.5-thinking': 'claude-4.5-sonnet-thinking', // haiku 'haiku-4.5': 'claude-4.5-haiku', // older 'sonnet-4': 'claude-4-sonnet', 'opus-4': 'claude-4-opus', 'opus-4.1': 'claude-4.1-opus', 'sonnet-3.7': 'claude-3.7-sonnet', 'sonnet-3.5': 'claude-3.5-sonnet', // ws-* prefix variant (even safer against future whitelist updates) 'ws-opus': 'claude-opus-4.6', 'ws-sonnet': 'claude-sonnet-4.6', 'ws-opus-thinking': 'claude-opus-4.6-thinking', 'ws-sonnet-thinking': 'claude-sonnet-4.6-thinking', 'ws-haiku': 'claude-4.5-haiku', }; for (const [k, v] of Object.entries(CURSOR_ALIASES)) _lookup.set(k, v); /** Resolve user model name → internal model key. */ export function resolveModel(name) { if (!name) return null; return _lookup.get(name) || _lookup.get(name.toLowerCase()) || name; } /** Get model info including enum and uid. */ export function getModelInfo(id) { return MODELS[id] || null; } // v2.0.84 (#118 0a00) — when an entire account pool is rate-limited // on a high-effort variant (`-max` / `-xhigh` / `-thinking-1m`), find // a same-base lower-effort variant the user could fall back to. Used // for two purposes: // 1. Error remediation: include the suggested model in the 429 // response so the client can switch transparently. // 2. Optional auto-fallback (env opt-in): proxy retries the same // request against the lower variant before reporting failure. // // Returns null when no lower variant exists in the catalog. Effort // ladder is suffix-only — we don't infer ladders, we read them off // the literal model-key suffix. // // Suffix order: less expensive first → more expensive last. const EFFORT_LADDER = [ // Anthropic effort tiers 'low', 'medium', 'high', 'xhigh', 'max', // GPT codex max sub-tiers (claude has -low, -medium, -high; gpt // codex has -low / -medium / -high stacked under -max-) ]; const CONTEXT_LADDER = ['1m']; // 1m context variants are weekly-quota'd // v2.0.89 (audit follow-up to v2.0.88 H-1.5): cascade pool alias // fingerprint relies on `toolPreamble` being IDENTICAL between the // stored fpAfterAlias and the next-turn fpBefore. toolPreamble depends // on the dialect picked for (modelKey, provider, route). Inside one // provider the dialect normally stays the same, so the alias slot // fingerprint matches the next-turn lookup. But a cross-provider // fallback (e.g. anthropic claude-opus → openai gpt-5.5) would build // the alias slot with the gpt_native dialect's toolPreamble while the // next turn rebuilds with claude's dialect → silent fingerprint // mismatch → cascade reuse miss → model "forgets" prior turns again, // regressing the v2.0.87 fix that the v2.0.88 alias write was meant // to enforce. // // Today the EFFORT_LADDER and CONTEXT_LADDER walk only ever stays // inside the same base model name (claude-opus-4-7-* siblings are all // anthropic; codex max-* are all openai). But this is fragile — // future catalog edits could produce a cross-provider candidate by // accident. Add a hard guard: only return a fallback that has the // same `provider` as the original. function _isSameProviderFallback(originalKey, candidateKey) { const o = MODELS[originalKey]; const c = MODELS[candidateKey]; if (!o || !c) return false; // No provider on either side → conservatively allow (matches old // behaviour for entries that haven't been catalogued with provider // metadata, though all current entries do have provider). if (!o.provider || !c.provider) return true; return o.provider === c.provider; } export function pickRateLimitFallback(modelKey) { if (!modelKey || typeof modelKey !== 'string') return null; // Try effort suffix first (e.g. -max → -xhigh → -high → -medium → -low) for (let i = EFFORT_LADDER.length - 1; i >= 1; i--) { const suffix = `-${EFFORT_LADDER[i]}`; if (modelKey.endsWith(suffix)) { const base = modelKey.slice(0, -suffix.length); // Walk DOWN the ladder until we find a key actually in the catalog // AND from the same provider (cascade pool alias requires same // dialect → same toolPreamble → same fingerprint). for (let j = i - 1; j >= 0; j--) { const candidate = `${base}-${EFFORT_LADDER[j]}`; if (MODELS[candidate] && _isSameProviderFallback(modelKey, candidate)) return candidate; } } } // 1m context variants → drop -1m for (const suffix of CONTEXT_LADDER) { const dashed = `-${suffix}`; if (modelKey.endsWith(dashed)) { const candidate = modelKey.slice(0, -dashed.length); if (MODELS[candidate] && _isSameProviderFallback(modelKey, candidate)) return candidate; } } // -thinking variants don't have a simple ladder; the natural fallback // is the non-thinking sibling, but that changes user-visible behaviour // (no reasoning content). Skip auto-fallback for those. return null; } // Reverse map: Model enum number → list of catalog keys (enum may match // multiple variants if we ever dupe, but typically 1:1). const _enumToKeys = (() => { const m = new Map(); for (const [key, info] of Object.entries(MODELS)) { if (info.enumValue && info.enumValue > 0) { const arr = m.get(info.enumValue) || []; arr.push(key); m.set(info.enumValue, arr); } } return m; })(); /** Reverse-lookup a Model enum number to our catalog keys. */ export function getModelKeysByEnum(enumValue) { return _enumToKeys.get(enumValue) || []; } // ─── Tier access ─────────────────────────────────────────── // Cold-start floor for free accounts — deliberately ONE model. // // v2.0.0 (4b72ecd) cut this from `['gpt-4o-mini', 'gemini-2.5-flash']` because // the extra enum-only entries were no longer routed upstream. #149's closure // reason ("free tier = gemini-2.5-flash + glm-4.7 + kimi-k2") is a per-account // PROBE RESULT, not this table: glm-4.7 (enum 417) and kimi-k2 (enum 323) are // enum-keyed, so GetUserStatus's authoritative allowlist supplies them for // accounts the upstream actually entitles; UID-only models arrive through // registerDiscoveredFreeModel() after a successful call. Adding guesses here // advertises models the upstream did not entitle, and those requests fail // upstream (permission_denied → 529) instead of with a clean 403. // Pinned by test/free-tier-cold-start.test.js; decision memo in // evidence/Wave4-Models.md §2. const FREE_TIER_BASE = ['gemini-2.5-flash']; const _discoveredFreeModels = new Set(); export function registerDiscoveredFreeModel(key) { if (MODELS[key] && !FREE_TIER_BASE.includes(key)) _discoveredFreeModels.add(key); } // ─── Cloud catalog filter ───────────────────────────────── // GetCascadeModelConfigs is fetched with one upstream account. Keep each // account's response separate: the pool-wide catalog is their union, while // routing checks the catalog for the candidate account only. Special-agent // backends keep their own catalog and are not governed by this response. const DEFAULT_CLOUD_CATALOG_ACCOUNT = Symbol('default-cloud-catalog-account'); const _cloudCatalogUidsByAccount = new Map(); const _activeCloudCatalogAccounts = new Set(); const _pendingCloudCatalogUidsByAccount = new Map(); // Consecutive rounds this account's shrunken snapshot has gone unconfirmed without // ever repeating the same UID set. See CLOUD_CATALOG_RECHECK_MAX_ROUNDS. const _cloudCatalogQuarantineRoundsByAccount = new Map(); // Accounts observed at least once to have a NON-EMPTY upstream catalog. // // Separate from the snapshot itself because the snapshot is deliberately dropped // whenever the account needs a re-fetch — a key rotation, a status flip, leaving // and re-entering the active set. If the "did this account ever have a filter?" // question were answered from the snapshot alone, every one of those routine // events would reopen the unguarded path: the next empty response would read as // "this deployment has no cloud filter", fail open, and re-advertise everything. // Measured before this existed: one disable/enable cycle plus one empty body took // an account from 148/163 to 163/163 with zero confirmation rounds. // // Cleared only when the account is genuinely gone (forgetCloudModelCatalog, called // from removeAccount) or on a full reset. Keeping it is the safe direction: a stale // marker only makes the empty-response guard STRICTER. const _cloudCatalogEverFilteredAccounts = new Set(); // Per-account cost tier + promotion, keyed by selector (lowercased modelUid). // // GetCascadeModelConfigs sends `modelCostTier` ("MODEL_COST_TIER_FREE" | LOW | // MEDIUM | HIGH) and `promoStatus` ({ isActive, endDate, label? }) on every row. // That pair is the upstream's OWN answer to "does this model debit quota right // now", and it is the only source that can see a plan-scoped promotion: the // credit rate table carries LIST price (swe-2-medium is 6, not 0) and the static // table has no tier field at all. Measured on a live Pro account (2026-10-22): // exactly three configs are MODEL_COST_TIER_FREE — swe-2-medium/high/max — with // an active promo ending 2026-11-01T06:59:59Z, while the account's rate table has // zero zero-rate entries. // // Deliberately NOT written onto MODELS[key].caps: promos are account/plan scoped // and MODELS is the shared catalog for the whole pool. A global row would leak one // account's promotion to every other account (and outlive its end date). Same // reason _cloudCatalogUidsByAccount is per account. // // Only an ACCEPTED, non-empty snapshot replaces an account's map, mirroring the // UID filter's fail-conservative rule: a response that omits the fields must not // relabel a known promotion as unknown. const _cloudCostTiersByAccount = new Map(); // Model keys that came from a live catalog snapshot rather than the static table. // // applyCloudModels adds keys for UIDs the static table doesn't know, and nothing ever // removed them: a deleted account's models stayed advertised forever. Measured — // inject one account-only UID, removeAccount, then pool total is 0 while the key is // still in MODELS and still returned by listModels. // // Deliberately NOT keyed by account. applyCloudModels skips a UID that already exists, // so only the FIRST account to report it would get an injection record — and then // removing the SECOND account (the last real holder) would withdraw nothing and leak // the key anyway. Measured that exact failure while building this. So the set is global // and eviction asks the only question that actually matters: does any remaining // account's catalog still contain this UID? const _injectedModelKeys = new Set(); const CLOUD_CATALOG_CONFIRM_RATIO = 0.5; // How many consecutive UNCONFIRMED shrink rounds the caller keeps re-checking // before it gives up and leaves the last-known-good in place. This bounds the // polling only — nothing unconfirmed is ever adopted, no matter how many rounds // pass. See the long note at the confirmation branch for why an earlier version // of this counter (which DID adopt the newest candidate on exhaustion) was worse // than the wedge it was meant to fix. const CLOUD_CATALOG_RECHECK_MAX_ROUNDS = 4; function normalizeCloudCatalogUid(uid) { return typeof uid === 'string' ? uid.trim().toLowerCase() : ''; } const STATIC_CLOUD_CATALOG_UID_COUNT = new Set( Object.values(MODELS) .filter(model => !model?.deprecated && model?.backend !== 'special_agent') .map(model => normalizeCloudCatalogUid(model?.modelUid)) .filter(Boolean), ).size; function cloudCatalogAccountKey(accountId) { if (accountId === undefined || accountId === null || accountId === '') { return DEFAULT_CLOUD_CATALOG_ACCOUNT; } return String(accountId); } /** * Cost tier + promotion off one upstream ClientModelConfig. * * Shape measured live (2026-10-22): * modelCostTier: "MODEL_COST_TIER_FREE" * promoStatus: { isActive: true, endDate: "2026-11-01T06:59:59.999999999Z" } * and, for a discounted-but-paid row: * modelCostTier: "MODEL_COST_TIER_HIGH" * promoStatus: { isActive: true, endDate: "2027-01-01T07:59:59.999999999Z", label: "83% off" } * * Returns null when the row carries neither field: "the upstream did not say" must * stay distinguishable from "it said FREE". */ function cloudCostInfo(m) { if (!m || typeof m !== 'object') return null; const costTier = typeof m.modelCostTier === 'string' && m.modelCostTier.trim() ? m.modelCostTier.trim() : null; const promo = m.promoStatus && typeof m.promoStatus === 'object' ? m.promoStatus : null; const endRaw = typeof promo?.endDate === 'string' ? promo.endDate : null; // The wire uses 9 fractional digits; V8's parser accepts them (verified: // 2026-11-01T06:59:59.999999999Z → 1793516399999). A garbage date must not // become a bogus expiry — keep null and let isCostTierFreeNow ignore it. const endMs = endRaw ? Date.parse(endRaw) : NaN; const info = { costTier, promoActive: typeof promo?.isActive === 'boolean' ? promo.isActive : undefined, promoEndsAt: Number.isFinite(endMs) ? new Date(endMs).toISOString() : null, promoEndsAtMs: Number.isFinite(endMs) ? endMs : null, promoLabel: typeof promo?.label === 'string' && promo.label.trim() ? promo.label.trim() : null, }; if (!info.costTier && !info.promoEndsAt && info.promoActive === undefined) return null; return Object.freeze(info); } /** * Does this cost-tier row debit quota right now? * * FREE is the upstream's own "no charge" statement, so a FREE row with no promo * metadata counts. An explicitly INACTIVE promo, or an end date in the past, means * the promotion is over — the row is then treated as billable even if the tier * field still says FREE, because the conservative direction is the one that does * not spend someone's quota. */ export function isCostTierFreeNow(info, now = Date.now()) { if (!info || info.costTier !== 'MODEL_COST_TIER_FREE') return false; if (info.promoActive === false) return false; if (info.promoEndsAtMs != null && info.promoEndsAtMs <= now) return false; return true; } /** True once any account has contributed cost-tier rows. */ export function hasCloudCostTierData() { for (const map of _cloudCostTiersByAccount.values()) { if (map.size) return true; } return false; } /** * Cost-tier info for one selector, pooled across accounts. * * Pool-wide on purpose, like the rate-table free set: "free now" answers "can this * deployment run it without debiting SOME account's quota", and a promo active for * one plan is enough. When accounts disagree, a currently-free row wins over an * expired one so a stale account cannot mask a live promotion. */ export function getCloudCostTierInfo(selector) { const key = typeof selector === 'string' ? selector.trim().toLowerCase() : ''; if (!key) return null; let found = null; for (const map of _cloudCostTiersByAccount.values()) { const info = map.get(key); if (!info) continue; if (!found) { found = info; continue; } if (!isCostTierFreeNow(found) && isCostTierFreeNow(info)) found = info; } return found; } /** * Selectors at least one account reports as free right now (promo-aware), or null * when no account has contributed cost-tier data. Null and an empty Set answer * different questions — "no data" must not read as "nothing is free". */ export function getCloudCostTierFreeSelectors(now = Date.now()) { if (!hasCloudCostTierData()) return null; const free = new Set(); for (const map of _cloudCostTiersByAccount.values()) { for (const [selector, info] of map) { if (isCostTierFreeNow(info, now)) free.add(selector); } } return free; } // ── Billing sync: rate + freshness ───────────────────────────────────────── // // GetCascadeModelConfigs carries the credit rate (`creditMultiplier`) on the SAME // rows as `modelCostTier`/`promoStatus`, and the catalog sync already fetches it. // Keeping only the cost tier threw that half away, so "free by a zero rate" could // only ever be answered by GetUserStatus's rate table — which a free account may // never receive at all. These maps make the rate a first-class synced fact. // // Both maps are per account for the same reason the cost tiers are: a promotion // or a plan-scoped rate belongs to the account that reported it, and pooling at // write time would let one account's row outlive its own plan. const _cloudRatesByAccount = new Map(); // When each account's billing snapshot (cost tier + rate) was last REPLACED. // Deliberately not "last fetched": a response that omitted the fields keeps the // last-known-good rows, so the honest age of those rows is the older timestamp. // That is what makes staleness visible instead of silently trusted. const _cloudBillingFetchedAtByAccount = new Map(); // How old a billing snapshot may get before the dashboard says so. 2h is 4× the // default 30-min refresh interval, so one missed round does not flip the badge, // while a stopped sync becomes visible the same working day. Env override for // operators who run a much slower cadence. const CLOUD_BILLING_STALE_MS = (() => { const raw = Number(process.env.BILLING_SYNC_STALE_MS); return Number.isFinite(raw) && raw > 0 ? raw : 2 * 60 * 60 * 1000; })(); /** True once any account has contributed a numeric credit rate. */ export function hasCloudRateData() { for (const map of _cloudRatesByAccount.values()) { if (map.size) return true; } return false; } /** * The upstream's own credit rate for one selector, pooled across accounts. * * Returns `{ credit, fetchedAt }` or null. A zero rate is a real answer (the * upstream charges nothing); "no row" stays null so absence never reads as free. */ export function getCloudCreditRate(selector) { const key = typeof selector === 'string' ? selector.trim().toLowerCase() : ''; if (!key) return null; let found = null; for (const [accountKey, map] of _cloudRatesByAccount) { const credit = map.get(key); if (typeof credit !== 'number') continue; const fetchedAt = _cloudBillingFetchedAtByAccount.get(accountKey) || 0; if (!found) { found = { credit, fetchedAt }; continue; } // Same "free wins" rule as the cost tiers: a zero rate from any account is // enough to say this deployment can run it without spending quota. if (found.credit !== 0 && credit === 0) found = { credit, fetchedAt }; else if (credit === found.credit && fetchedAt > found.fetchedAt) found = { credit, fetchedAt }; } return found; } /** * Selectors at least one account's catalog prices at exactly 0 credits, or null * when no account has contributed a numeric rate. * * Strictly zero, like the account rate table: a 0.5-credit row still bills, and * calling it free is the failure that spends someone's quota. */ export function getCloudZeroRateSelectors() { if (!hasCloudRateData()) return null; const free = new Set(); for (const map of _cloudRatesByAccount.values()) { for (const [selector, credit] of map) { if (credit === 0) free.add(selector); } } return free; } /** When the newest billing snapshot was applied, or 0 when none ever was. */ export function getCloudBillingFetchedAt() { let newest = 0; for (const ts of _cloudBillingFetchedAtByAccount.values()) { if (ts > newest) newest = ts; } return newest; } /** Is the newest billing snapshot older than the staleness budget? */ export function isCloudBillingStale(now = Date.now()) { const fetchedAt = getCloudBillingFetchedAt(); return fetchedAt > 0 && now - fetchedAt > CLOUD_BILLING_STALE_MS; } /** * One snapshot of the billing-sync state for the dashboard. * * `accounts` and the two row counts say WHAT is known; `fetchedAt`/`stale` say * HOW current it is. An operator needs both: "3 free rows" is meaningless if the * last successful sync was yesterday. */ export function getCloudBillingSyncState(now = Date.now()) { let costTierRows = 0; let rateRows = 0; for (const map of _cloudCostTiersByAccount.values()) costTierRows += map.size; for (const map of _cloudRatesByAccount.values()) rateRows += map.size; const fetchedAt = getCloudBillingFetchedAt(); return { fetchedAt: fetchedAt || null, stale: isCloudBillingStale(now), staleAfterMs: CLOUD_BILLING_STALE_MS, accounts: new Set([..._cloudCostTiersByAccount.keys(), ..._cloudRatesByAccount.keys()]).size, costTierRows, rateRows, freeRows: getCloudCostTierFreeSelectors(now)?.size ?? 0, }; } /** * Billing facts for one selector, from the synced upstream snapshots only. * * Precedence is the project's documented one: the cost tier is the upstream's own * "no charge" statement, so it wins; otherwise a zero credit rate is the same * statement in the other field. `freeNow: null` means the upstream has said * NOTHING about this selector — never fold that into false here, because callers * still have the static cold-start fallback to consider. */ export function getCloudSelectorPricing(selector, now = Date.now()) { const info = getCloudCostTierInfo(selector); const costFree = info ? isCostTierFreeNow(info, now) : false; const rate = getCloudCreditRate(selector); const base = { costTier: info?.costTier || null, promoEndsAt: info?.promoEndsAt || null, promoLabel: info?.promoLabel || null, rate: rate ? rate.credit : null, fetchedAt: rate?.fetchedAt || getCloudBillingFetchedAt() || null, }; if (costFree) return { ...base, freeNow: true, freeReason: 'cost_tier_free' }; if (rate && rate.credit === 0) return { ...base, freeNow: true, freeReason: 'rate_zero' }; if (info || rate) return { ...base, freeNow: false, freeReason: null }; return { ...base, freeNow: null, freeReason: null }; } /** * Apply the billing half of one accepted GetCascadeModelConfigs snapshot. * * Split out of applyCloudModels so the periodic billing refresh can update cost * tier + rate WITHOUT touching the model-UID filter snapshot or its "this account * has synced" latch. Those answer different questions: the UID set gates what may * be advertised, while these rows price what is already known. Re-fetching one * must never let a transient shrunken catalog overwrite the other. * * Replace rule: a response that carries at least one cost-tier row (or one numeric * rate) replaces that account's map wholesale, so a model the upstream stopped * marking free loses its stale promotion. A response that carries NONE of the * fields is a partial body and keeps the last-known-good rows — the same * fail-conservative rule the UID filter uses. */ export function mergeCloudCostTiers(configs, { accountId = null, now = Date.now() } = {}) { const accountKey = cloudCatalogAccountKey(accountId); const safeConfigs = Array.isArray(configs) ? configs : []; const nextCostTiers = new Map(); const nextRates = new Map(); for (const m of safeConfigs) { const uid = normalizeCloudCatalogUid(m?.modelUid); if (!uid) continue; const info = cloudCostInfo(m); if (info) nextCostTiers.set(uid, info); const credit = Number(m?.creditMultiplier); if (m?.creditMultiplier !== null && m?.creditMultiplier !== undefined && Number.isFinite(credit) && credit >= 0) { nextRates.set(uid, credit); } } let applied = false; if (nextCostTiers.size > 0) { _cloudCostTiersByAccount.set(accountKey, nextCostTiers); applied = true; } if (nextRates.size > 0) { _cloudRatesByAccount.set(accountKey, nextRates); applied = true; } if (applied) _cloudBillingFetchedAtByAccount.set(accountKey, now); return { applied, costTierRows: nextCostTiers.size, rateRows: nextRates.size, fetchedAt: applied ? now : (_cloudBillingFetchedAtByAccount.get(accountKey) || null), }; } /** * Set the accounts that currently contribute to pool-wide model listings. * * A listing fails open until every active account has a usable catalog. This * prevents a newly-added or temporarily-unsynced account from having models * hidden before its own upstream response arrives. */ export function setActiveCloudCatalogAccounts(accountIds) { const nextActiveAccounts = new Set(); for (const accountId of accountIds || []) { if (accountId === undefined || accountId === null || accountId === '') continue; nextActiveAccounts.add(String(accountId)); } for (const accountId of _activeCloudCatalogAccounts) { if (nextActiveAccounts.has(accountId)) continue; _cloudCatalogUidsByAccount.delete(accountId); _pendingCloudCatalogUidsByAccount.delete(accountId); _cloudCatalogQuarantineRoundsByAccount.delete(accountId); _cloudCostTiersByAccount.delete(accountId); _cloudRatesByAccount.delete(accountId); _cloudBillingFetchedAtByAccount.delete(accountId); } _activeCloudCatalogAccounts.clear(); for (const accountId of nextActiveAccounts) _activeCloudCatalogAccounts.add(accountId); } /** * Remove one account's catalog when it leaves the active pool or its key changes. * * Deliberately does NOT clear the "this account has had a filter" marker: the * account is still the same account and still subject to the same upstream * restriction, so an empty response after this must not be read as "no filter". * Use forgetCloudModelCatalog when the account itself is gone. */ export function removeCloudModelCatalog(accountId) { const accountKey = cloudCatalogAccountKey(accountId); _cloudCatalogUidsByAccount.delete(accountKey); _pendingCloudCatalogUidsByAccount.delete(accountKey); _cloudCatalogQuarantineRoundsByAccount.delete(accountKey); _cloudCostTiersByAccount.delete(accountKey); _cloudRatesByAccount.delete(accountKey); _cloudBillingFetchedAtByAccount.delete(accountKey); } /** Drop every trace of an account, including that it ever had a catalog. */ export function forgetCloudModelCatalog(accountId) { const accountKey = cloudCatalogAccountKey(accountId); // Order matters, opposite to the first attempt: drop this account's catalog FIRST so // its own UIDs stop counting as reachable, THEN evict whatever no surviving account // still claims. Evicting first would always see this account's UIDs and keep everything. removeCloudModelCatalog(accountId); _cloudCatalogEverFilteredAccounts.delete(accountKey); evictUnreachableInjectedModels(); } /** Clear all in-memory catalog state. Primarily useful for deterministic tests. */ export function clearCloudModelCatalogs() { _injectedModelKeys.clear(); _cloudCatalogUidsByAccount.clear(); _pendingCloudCatalogUidsByAccount.clear(); _cloudCatalogQuarantineRoundsByAccount.clear(); _cloudCatalogEverFilteredAccounts.clear(); _cloudCostTiersByAccount.clear(); _cloudRatesByAccount.clear(); _cloudBillingFetchedAtByAccount.clear(); _activeCloudCatalogAccounts.clear(); } function applicableCloudCatalogUids(accountId) { if (accountId !== undefined && accountId !== null && accountId !== '') { const catalog = _cloudCatalogUidsByAccount.get(cloudCatalogAccountKey(accountId)); return catalog?.size ? catalog : null; } if (_activeCloudCatalogAccounts.size > 0) { const union = new Set(); for (const activeAccountId of _activeCloudCatalogAccounts) { const catalog = _cloudCatalogUidsByAccount.get(activeAccountId); if (!catalog?.size) return null; for (const uid of catalog) union.add(uid); } return union; } const fallback = _cloudCatalogUidsByAccount.get(DEFAULT_CLOUD_CATALOG_ACCOUNT); return fallback?.size ? fallback : null; } function isModelAllowedByCatalogUids(key, catalogUids) { if (!catalogUids) return true; const model = MODELS[key]; if (!model) return false; if (model.backend === 'special_agent') return true; const uid = normalizeCloudCatalogUid(model.modelUid); return uid !== '' && catalogUids.has(uid); } /** Whether one model key is permitted by the relevant upstream account catalog. */ export function isModelAllowedByCloudCatalog(key, env = process.env, accountId = null) { if (env.WINDSURFAPI_IGNORE_CLOUD_FILTER === '1') return true; // DEVIN_CONNECT uses a separate selector namespace and catalog source. // Applying GetCascadeModelConfigs here can turn a valid Connect-only // allowlist (for example swe-1-6-slow) into an empty model view because the // selector has no equivalent entry in the Cascade MODELS table. if (getBackendSwitch('devinConnect', env)) return true; const catalogUids = applicableCloudCatalogUids(accountId); return isModelAllowedByCatalogUids(key, catalogUids); } /** * Return model keys allowed by an account catalog or by the active pool union. * * Passing accountId applies only that account's response. Omitting it applies * the union used by pool-wide model listings. Before every relevant catalog is * usable (or when explicitly disabled), this fails open. */ export function filterModelKeysByCloudCatalog( keys = Object.keys(MODELS), env = process.env, accountId = null, ) { const input = Array.from(keys || []); if (env.WINDSURFAPI_IGNORE_CLOUD_FILTER === '1') return input; if (getBackendSwitch('devinConnect', env)) return input; const catalogUids = applicableCloudCatalogUids(accountId); if (!catalogUids) return input; return input.filter((key) => isModelAllowedByCatalogUids(key, catalogUids)); } function baseTierModels(tier) { if (tier === 'free') return [...FREE_TIER_BASE, ..._discoveredFreeModels]; if (tier === 'expired') return []; return Object.keys(MODELS); } export const MODEL_TIER_ACCESS = { get pro() { return filterModelKeysByCloudCatalog(baseTierModels('pro')); }, get free() { return filterModelKeysByCloudCatalog(baseTierModels('free')); }, // Optimistic: a freshly-added account whose probe hasn't completed yet // gets the FULL pro catalog, not just gemini-2.5-flash. Otherwise the // chat.js anyEligible check (line ~1141) immediately 403s any non-free // model with "模型 X 在当前账号池中不可用", and users see "添加账号后 // 不能调用任何模型" until probe finishes ~10-30s later. Trade-off: a // free user may try opus before probe completes; the request will fail // upstream with a real entitlement error from the LS, which is a more // accurate failure than the misleading "model not in account pool" we // were emitting. Reported in QQ group, 2026-04-30. get unknown() { return filterModelKeysByCloudCatalog(baseTierModels('unknown')); }, expired: [], }; /** Models a given tier is entitled to. */ export function getTierModels(tier, accountId = null, env = process.env) { const resolvedTier = ['pro', 'free', 'unknown', 'expired'].includes(tier) ? tier : 'unknown'; return filterModelKeysByCloudCatalog(baseTierModels(resolvedTier), env, accountId); } function isSpecialAgentCatalogEnabled() { // DEVIN_ONLY retires Cascade and makes Devin the only backend, so the // special-agent models must stay listed. Routing itself is handled in // backend-router.selectBackend() (DEVIN_ONLY short-circuits there); this // only keeps the catalog/listing consistent with that mode. if (getBackendSwitch('devinOnly')) return true; const backend = String(process.env.WINDSURFAPI_SPECIAL_AGENT_BACKEND || '').trim().toLowerCase(); return backend === 'devin-cli' || process.env.DEVIN_CLI_ENABLED === '1'; } // O13: OpenAI `created` is a stable unix-seconds stamp, not per-request now(). export const MODEL_CREATED = 1704067200; /** List all models in OpenAI /v1/models format. Hides deprecated models. */ export function listModels(opts = {}) { const env = opts.env ?? process.env; const specialAgentEnabled = opts.specialAgentEnabled ?? isSpecialAgentCatalogEnabled(); const includeDisabledSpecialAgent = opts.includeDisabledSpecialAgent ?? process.env.WINDSURFAPI_SHOW_DISABLED_SPECIAL_AGENT_MODELS === '1'; return filterModelKeysByCloudCatalog(Object.keys(MODELS), env) .map((id) => [id, MODELS[id]]) .filter(([, info]) => !info.deprecated) .filter(([, info]) => info.backend !== 'special_agent' || specialAgentEnabled || includeDisabledSpecialAgent) .map(([id, info]) => ({ id: info.name, object: 'model', created: MODEL_CREATED, owned_by: info.provider, _windsurf_id: id, ...(info.backend === 'special_agent' ? { _backend: 'special_agent', _available: !!specialAgentEnabled, ...(!specialAgentEnabled ? { _unavailable_reason: 'special-agent backend disabled' } : {}), } : {}), })); } function applyCloudModels(configs, { accountId = null } = {}) { const catalogAccountKey = cloudCatalogAccountKey(accountId); const safeConfigs = Array.isArray(configs) ? configs : []; _pendingCloudCatalogUidsByAccount.delete(catalogAccountKey); // Replace this account's policy snapshot atomically. An empty/invalid // response removes it so filtering remains fail-open for that account. const nextCloudCatalogUids = new Set(); for (const model of safeConfigs) { const uid = normalizeCloudCatalogUid(model?.modelUid); if (uid) nextCloudCatalogUids.add(uid); } if (nextCloudCatalogUids.size > 0) { _cloudCatalogUidsByAccount.set(catalogAccountKey, nextCloudCatalogUids); } else { _cloudCatalogUidsByAccount.delete(catalogAccountKey); } // Same account-scoped replace as the UID set, with the same "never blank on a // silent response" rule. `applyCloudModels` only runs for ACCEPTED snapshots, so // reaching here means the upstream actually answered for this account. // // Delegated to mergeCloudCostTiers (the single writer) so the periodic billing // refresh and this catalog sync cannot drift apart about what a snapshot means. mergeCloudCostTiers(safeConfigs, { accountId }); let added = 0; const providerMap = { MODEL_PROVIDER_ANTHROPIC: 'anthropic', MODEL_PROVIDER_OPENAI: 'openai', MODEL_PROVIDER_GOOGLE: 'google', MODEL_PROVIDER_DEEPSEEK: 'deepseek', MODEL_PROVIDER_XAI: 'xai', MODEL_PROVIDER_WINDSURF: 'windsurf', MODEL_PROVIDER_MOONSHOT: 'moonshot', }; for (const m of safeConfigs) { const uid = typeof m?.modelUid === 'string' ? m.modelUid.trim() : ''; if (!uid) continue; // Already in catalog? if (_lookup.has(uid) || _lookup.has(uid.toLowerCase())) continue; const key = uid.toLowerCase().replace(/_/g, '-'); if (MODELS[key]) continue; const provider = providerMap[m.provider] || m.provider?.toLowerCase()?.replace('model_provider_', '') || 'unknown'; MODELS[key] = { name: key, provider, enumValue: 0, modelUid: uid, credit: m.creditMultiplier || 1, // Capability data the upstream already sent and this merge used to discard. // GetCascadeModelConfigs returns the full ClientModelConfig; keeping only // uid/provider/credit meant every capability question (does this model take // images? what is its real output ceiling? is the tier allowed to use it?) // had to be answered from a hardcoded table or by spending a chat roundtrip // and reading the error. `disabled` is the load-bearing one: it is the // upstream's own "this account's tier may not use this model", and without // it a tier-forbidden model is indistinguishable from a transient fault. // // Undefined when absent rather than defaulted — a missing field means "the // upstream did not say", which must not read as "false". caps: cloudModelCaps(m), }; _lookup.set(key, key); _lookup.set(uid, key); _lookup.set(uid.toLowerCase(), key); // Mark this key as snapshot-derived so it can be withdrawn once no account reaches // its UID. Without this the table only ever grows. _injectedModelKeys.add(key); added++; } return added; } /** * Withdraw snapshot-derived models that no remaining account can reach. * * Called after an account's catalog has been dropped. Reachability is decided from the * surviving accounts' UID sets, NOT from who originally injected the key — see the note * on _injectedModelKeys for why an injector-based rule leaks the last-holder case. * * Static-table models are never touched: only keys in _injectedModelKeys are candidates. */ function evictUnreachableInjectedModels() { if (!_injectedModelKeys.size) return 0; const reachableUids = new Set(); for (const uids of _cloudCatalogUidsByAccount.values()) { for (const uid of uids) reachableUids.add(uid); } let removed = 0; for (const key of [..._injectedModelKeys]) { const model = MODELS[key]; if (!model) { _injectedModelKeys.delete(key); continue; } const uid = normalizeCloudCatalogUid(model.modelUid); if (uid && reachableUids.has(uid)) continue; // some account still reaches it const rawUid = typeof model.modelUid === 'string' ? model.modelUid.trim() : ''; delete MODELS[key]; _lookup.delete(key); if (rawUid) { _lookup.delete(rawUid); _lookup.delete(rawUid.toLowerCase()); } _injectedModelKeys.delete(key); removed++; } return removed; } /** * Merge live model configs from GetCascadeModelConfigs into the catalog. * Account-scoped snapshots pass through the shrink-confirmation guard. The * default legacy catalog remains a direct merge for compatibility. * Only adds NEW models not already in the catalog (doesn't overwrite enums). */ export function mergeCloudModels(configs, { accountId = null } = {}) { if (accountId === undefined || accountId === null || accountId === '') { return applyCloudModels(configs, { accountId }); } return mergeCloudCatalogSnapshot(configs, { accountId }).added; } function cloudCatalogUidSet(configs) { const uids = new Set(); for (const model of configs || []) { const uid = normalizeCloudCatalogUid(model?.modelUid); if (uid) uids.add(uid); } return uids; } /** * Capability fields off one upstream ClientModelConfig. * * Every value is left `undefined` when the upstream did not send it. That * distinction matters most for `disabled`: `false` means "the upstream says this * account may use it", `undefined` means "we were not told", and only the former * is safe to act on. Defaulting the absent case to `false` would turn silence * into permission — the fail-open direction this catalog is careful about * everywhere else (see mergeCloudCatalogSnapshot's empty/malformed handling). * * Field names follow the upstream JSON (camelCase off Connect's JSON codec). */ function cloudModelCaps(m) { if (!m || typeof m !== 'object') return undefined; const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined); const bool = (v) => (typeof v === 'boolean' ? v : undefined); const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined); const caps = { // The tier gate. Upstream's own answer to "may this account use this model". disabled: bool(m.disabled), // Human-facing reason that pairs with `disabled` — real text for the opaque // "model not enabled" the dashboard used to show with no explanation. disabledReason: str(m.disabledReason?.shortReason || m.disabledReason?.description || m.disabledReason), supportsImages: bool(m.supportsImages), isCapacityLimited: bool(m.isCapacityLimited), maxTokens: num(m.maxTokens), maxOutputTokens: num(m.modelInfo?.maxOutputTokens ?? m.maxOutputTokens), maxInputTokens: num(m.maxNumChatInputTokens), costTier: str(m.modelCostTier), }; // Drop the keys the upstream stayed silent on so a consumer can use `in`. for (const k of Object.keys(caps)) if (caps[k] === undefined) delete caps[k]; return Object.keys(caps).length ? Object.freeze(caps) : undefined; } /** * Capability data for a model key, or null when the catalog has none. * * Null is the honest answer for a statically-catalogued model: the static table * carries no upstream capability data, and inventing defaults here would let a * caller believe the upstream vouched for something it never said. */ export function getModelCaps(modelKey) { if (!modelKey || typeof modelKey !== 'string') return null; const resolved = _lookup.get(modelKey) || _lookup.get(modelKey.toLowerCase()) || modelKey; return MODELS[resolved]?.caps || null; } /** * True only when the upstream explicitly said this model is disabled for the tier. * * `undefined` (not told) reads as NOT disabled on purpose: a preflight that * refuses on silence would break every statically-catalogued model and every * account whose catalog has not synced yet. */ export function isModelDisabledUpstream(modelKey) { return getModelCaps(modelKey)?.disabled === true; } function cloudCatalogSetsEqual(left, right) { if (!left || left.size !== right.size) return false; for (const uid of left) { if (!right.has(uid)) return false; } return true; } /** * Validate and merge one fetched account catalog snapshot. * * A non-empty snapshot that is less than half the account's last accepted * snapshot, or the static catalog when no account snapshot exists yet, is * quarantined until the same UID set is returned in a later sync round. */ export function mergeCloudCatalogSnapshot(configs, { accountId = null } = {}) { const accountKey = cloudCatalogAccountKey(accountId); const currentUids = _cloudCatalogUidsByAccount.get(accountKey); // "Has this account ever been under an upstream restriction?" — answered from the // durable marker as well as the live snapshot, because the snapshot is dropped by // routine re-fetch events (key rotation, status flip, leaving the active set) and // answering from it alone let every one of those reopen the empty-response hole. const everFiltered = _cloudCatalogEverFilteredAccounts.has(accountKey); const hasAcceptedSnapshot = !!currentUids?.size || everFiltered; // A malformed response is NO DATA, not "this account has no filter". It used to // call applyCloudModels([]) — which DELETES the account's snapshot — so a single // truncated / throttled / auth-blipped response silently restored the full // catalog and re-advertised every model the filter existed to hide (#231's own // symptom). Keep the last-known-good and let the caller retry. if (!Array.isArray(configs)) { return { accepted: false, added: 0, reason: 'malformed', receivedCount: 0, baselineCount: currentUids?.size || 0, baselineSource: hasAcceptedSnapshot ? 'last_accepted' : 'static', preservedLastKnownGood: hasAcceptedSnapshot, }; } const nextUids = cloudCatalogUidSet(configs); if (nextUids.size === 0) { // Zero UIDs is ambiguous, and the two readings need OPPOSITE handling — // because the consequences are asymmetric, not because one is rarer: // // - No snapshot accepted yet → "this deployment has no cloud filter". That // is the documented fail-open and stays immediate: for such a deployment // an empty response is the normal steady state. // - A snapshot WAS accepted before → this is NO DATA, and it must never // delete the filter. An earlier attempt at this routed the empty case // through the same confirmation as any other shrink, reasoning that empty // is just the maximal shrink. That was wrong on both counts. (a) Accepting // a small NON-EMPTY snapshot over-restricts — it fails CLOSED, the operator // sees fewer models and can act. Accepting an EMPTY one DELETES the // snapshot (applyCloudModels with no UIDs), which fails OPEN and // re-advertises every model the account may not have — #231's own symptom, // and via the pool union it drops the filter for every OTHER account too. // (b) Confirmation is the wrong instrument here: a throttled or blipped // upstream returns empty repeatedly, so a second identical empty body is // not evidence, it is the same non-answer twice. // // If an account genuinely loses its upstream restriction, the cost of this // rule is that a stale filter keeps over-restricting until the upstream sends // a non-empty catalog again — visible to the operator, and // WINDSURFAPI_IGNORE_CLOUD_FILTER=1 is the documented escape hatch. That is // the trade this project's own recurring lesson asks for: an absent answer is // "I don't know", never "it's fine". if (!hasAcceptedSnapshot) { _pendingCloudCatalogUidsByAccount.delete(accountKey); _cloudCatalogQuarantineRoundsByAccount.delete(accountKey); return { accepted: true, added: applyCloudModels(configs, { accountId }), reason: 'no_filter', receivedCount: 0, baselineCount: 0, }; } return { accepted: false, added: 0, reason: 'empty_over_snapshot', receivedCount: 0, baselineCount: currentUids?.size || 0, baselineSource: 'last_accepted', preservedLastKnownGood: true, }; } const baselineCount = currentUids?.size || STATIC_CLOUD_CATALOG_UID_COUNT; const baselineSource = hasAcceptedSnapshot ? 'last_accepted' : 'static'; const confirmationThreshold = Math.ceil(baselineCount * CLOUD_CATALOG_CONFIRM_RATIO); const needsConfirmation = nextUids.size < confirmationThreshold; if (needsConfirmation) { const pendingUids = _pendingCloudCatalogUidsByAccount.get(accountKey); const repeated = cloudCatalogSetsEqual(pendingUids, nextUids); if (!repeated) { // Count rounds only to bound how long we keep RE-CHECKING, never to lower // the bar for acceptance. An earlier attempt used this counter to accept the // newest candidate once the budget ran out, on the theory that "the upstream // has said it is small several times" beats a stale snapshot. Measured, that // was worse than the wedge it replaced: the counter is per-account, not per // candidate, so two unrelated rounds paid for a third candidate that was then // adopted the first time it was ever seen — including an empty body, which // fails wide open. And once adopted, auth.js records the account as synced and // stops re-fetching, so a 90-second upstream wobble permanently pinned an // account to a never-confirmed model list with no self-healing path. // // The wedge that motivated the ceiling is already gone: auth.js now arms the // delayed re-check on EVERY quarantined round (it used to arm only the first), // and a GENUINE downgrade is stable, so it repeats and gets confirmed in two // rounds by the ordinary path. An upstream that keeps changing its answer has // no stable answer to adopt — refusing to adopt one is the correct behaviour, // not a wedge. const rounds = (_cloudCatalogQuarantineRoundsByAccount.get(accountKey) || 0) + 1; _pendingCloudCatalogUidsByAccount.set(accountKey, nextUids); _cloudCatalogQuarantineRoundsByAccount.set(accountKey, rounds); return { accepted: false, added: 0, reason: 'confirmation_required', receivedCount: nextUids.size, baselineCount, baselineSource, quarantineRounds: rounds, // Past this many consecutive unconfirmed rounds the caller stops arming a // new re-check and leaves the last-known-good in place. This bounds the // polling, NOT the correctness bar — nothing unconfirmed is ever adopted. recheckRoundsMax: CLOUD_CATALOG_RECHECK_MAX_ROUNDS, recheckExhausted: rounds >= CLOUD_CATALOG_RECHECK_MAX_ROUNDS, preservedLastKnownGood: hasAcceptedSnapshot, }; } _pendingCloudCatalogUidsByAccount.delete(accountKey); _cloudCatalogQuarantineRoundsByAccount.delete(accountKey); _cloudCatalogEverFilteredAccounts.add(accountKey); return { accepted: true, added: applyCloudModels(configs, { accountId }), reason: 'confirmed_small', receivedCount: nextUids.size, baselineCount, baselineSource, }; } _pendingCloudCatalogUidsByAccount.delete(accountKey); _cloudCatalogQuarantineRoundsByAccount.delete(accountKey); _cloudCatalogEverFilteredAccounts.add(accountKey); return { accepted: true, added: applyCloudModels(configs, { accountId }), reason: 'accepted', receivedCount: nextUids.size, baselineCount, baselineSource, }; }