src/models.js
Rama master · réplica de lectura del repositorio original.
/**
* 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,
};
}