Ctrl K
ComunidadGuías…

src/devin-connect-models.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Model name → DEVIN_CONNECT selector resolver.
 *
 * GetChatMessageRequest.model_selector (proto field #21) takes a STRING selector,
 * not the Cascade modelUid/enum. The full catalog was frame-captured from a live
 * GetCliModelConfigs response (see docs-internal/workflow-results/devin-protobuf/
 * model-catalog-CAPTURED.md and memory: devin-getchatmessage-wire-calibrated-
 * 2026-06-30). This maps the OpenAI-style model names a client sends onto the
 * verified upstream selectors.
 *
 * Free-tier (unpaid) accounts are entitled only to `swe-1-6-slow`; every other
 * selector returns "/upgrade to access this model". That's an account-tier wall,
 * not a protocol gap. Which selectors are FREE to CALL is a different question
 * and is derived from the live catalog — a paid plan's promo can make e.g.
 * swe-2-medium/high/max free without moving them into the unpaid entitlement set
 * (see auth.js getConnectSelectorPricing and models.js cost-tier decoding).
 */

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { log } from './config.js';

// Canonical OpenAI-ish name / alias → upstream selector (#21). Both the
// dash-form and enum-form selectors are accepted by the API; we prefer the
// dash-form where the catalog exposed one.
const SELECTOR_MAP = new Map(Object.entries({
  // ── SWE / Cognition (free-tier reachable) ──
  'swe-1-6-slow': 'swe-1-6-slow',
  'swe-1.6-slow': 'swe-1-6-slow',
  'swe-1-6': 'swe-1-6',
  'swe-1.6': 'swe-1-6',
  'swe-1-6-fast': 'swe-1-6-fast',
  'swe-1.6-fast': 'swe-1-6-fast',
  'swe-1-5': 'MODEL_SWE_1_5_SLOW',
  'swe-1.5': 'MODEL_SWE_1_5_SLOW',
  'swe-1-5-fast': 'MODEL_SWE_1_5',
  'swe-1.5-fast': 'MODEL_SWE_1_5',
  'subagent-default': 'subagent-default',

  // ── Anthropic (paid) ──
  // opus-4-8 → -medium is frame-verified (see resolver header). The bare
  // Cursor/OpenAI-style forms (no `claude-` prefix) exist in models.js's alias
  // table, and chat.js passes the RAW request model name to resolveConnectSelector
  // (not the models.js-resolved key), so without these entries a client asking
  // for bare `opus-4-8` on the DEVIN_CONNECT path silently degrades to the free
  // selector (issue #203). All point at the same frame-verified catalog selector.
  'claude-opus-4-8': 'claude-opus-4-8-medium',
  'claude-opus-4.8': 'claude-opus-4-8-medium',
  'claude-opus-4-8-medium': 'claude-opus-4-8-medium',
  'opus-4-8': 'claude-opus-4-8-medium',
  'opus-4.8': 'claude-opus-4-8-medium',
  // NB: bare dashed `claude-sonnet-4-6` is itself a real catalog selector now
  // (the base, non-thinking model — reachable 2026-07-05), so it must resolve to
  // ITSELF via the catalog fallthrough, not be remapped. Only the dotted family
  // alias `claude-sonnet-4.6` carries the curated -thinking default.
  'claude-sonnet-4.6': 'claude-sonnet-4-6-thinking',
  'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6-thinking',
  'claude-opus-4-5': 'MODEL_CLAUDE_4_5_OPUS',
  'claude-opus-4.5': 'MODEL_CLAUDE_4_5_OPUS',
  'claude-opus-4-5-thinking': 'MODEL_CLAUDE_4_5_OPUS_THINKING',
  'claude-sonnet-4-5': 'MODEL_PRIVATE_2',
  'claude-sonnet-4.5': 'MODEL_PRIVATE_2',
  'claude-sonnet-4-5-thinking': 'MODEL_PRIVATE_3',
  'claude-haiku-4-5': 'MODEL_PRIVATE_11',
  'claude-haiku-4.5': 'MODEL_PRIVATE_11',

  // ── OpenAI (paid) ──
  // The catalog advertises bare `gpt-5.5` as the alias for gpt-5-5-low, so the
  // bare form must resolve too — otherwise a client sending the catalog's own
  // alias normalizes to `gpt-5-5`, misses the map, and silently degrades to the
  // free selector (mapped:false). Keep the bare + suffixed forms in lockstep.
  'gpt-5-5': 'gpt-5-5-low',
  'gpt-5.5': 'gpt-5-5-low',
  'gpt-5-5-low': 'gpt-5-5-low',
  'gpt-5.5-low': 'gpt-5-5-low',
  'gpt-5-2': 'MODEL_GPT_5_2_NONE',
  'gpt-5.2': 'MODEL_GPT_5_2_NONE',
  'gpt-5-2-low': 'MODEL_GPT_5_2_LOW',
  'gpt-5-2-medium': 'MODEL_GPT_5_2_MEDIUM',
  'gpt-5-2-high': 'MODEL_GPT_5_2_HIGH',
  'gpt-5-2-xhigh': 'MODEL_GPT_5_2_XHIGH',

  // ── Google (paid) ──
  // Catalog advertises the family alias as `gemini-3.0-flash` (with the .0),
  // which normalizes to `gemini-3-0-flash`. Keep both that and the shorter
  // `gemini-3-flash` form pointing at the MEDIUM default so the catalog's own
  // alias resolves instead of degrading to free.
  'gemini-3-0-flash': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM',
  'gemini-3.0-flash': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM',
  'gemini-3-flash': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM',
  'gemini-3-flash-minimal': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL',
  'gemini-3-flash-low': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW',
  'gemini-3-flash-medium': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM',
  'gemini-3-flash-high': 'MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH',

  // ── Others (paid) ──
  'glm-5-2': 'glm-5-2',
  'glm-5.2': 'glm-5-2',
  'kimi-k2-7': 'kimi-k2-7',

  // ── Paid roster confirmed reachable 2026-07-05 (teams token, direct-selector
  // probe, docs-internal/workflow-results/paid-live-2026-07-05/) ── each maps a catalog family
  // alias to its -medium (or base/low) default; all targets are in the refreshed
  // 105-model catalog snapshot. See RESULTS.md §2 for the reachability matrix.
  'claude-5-fable': 'claude-5-fable-medium',
  'claude-sonnet-5': 'claude-sonnet-5-medium',
  'claude-opus-4-7': 'claude-opus-4-7-medium',
  'claude-opus-4.7': 'claude-opus-4-7-medium',
  'claude-opus-4.6': 'claude-opus-4-6',
  'gpt-5-4': 'gpt-5-4-medium',
  'gpt-5.4': 'gpt-5-4-medium',
  'gpt-5-4-mini': 'gpt-5-4-mini-medium',
  'gpt-5.4-mini': 'gpt-5-4-mini-medium',
  'gpt-5-3-codex': 'gpt-5-3-codex-medium',
  'gpt-5.3-codex': 'gpt-5-3-codex-medium',
  'gemini-3-5-flash': 'gemini-3-5-flash-medium',
  'gemini-3.5-flash': 'gemini-3-5-flash-medium',
  'gemini-3-1-pro': 'gemini-3-1-pro-low',
  'gemini-3.1-pro': 'gemini-3-1-pro-low',
  // GLM-5.1 is the previous discovery id for the same upstream model (selector
  // glm-5-2). Clients send it both dotted and dashed; without the dashed row the
  // dashed spelling missed every map lookup, was absent from the live catalog,
  // and resolved to {selector:null, mapped:false} → 400 model_not_found, while
  // the dotted form worked. Keep both spellings on the same target.
  'glm-5.1': 'glm-5-2',
  'glm-5-1': 'glm-5-2',
  'kimi-k2.6': 'kimi-k2-6',
  'kimi-k2.7': 'kimi-k2-7',
  // New families in the live 105 catalog (2026-07): SWE-1.7 + DeepSeek V4 Pro.
  'swe-1-7': 'swe-1-7',
  'swe-1.7': 'swe-1-7',
  'swe-1-7-lightning': 'swe-1-7-lightning',
  'swe-1.7-lightning': 'swe-1-7-lightning',
  'swe-2': 'swe-2-medium',
  'swe-2.0': 'swe-2-medium',
  'swe-2-0': 'swe-2-medium',
  'swe2': 'swe-2-medium',
  'swe-2-medium': 'swe-2-medium',
  'swe-2-high': 'swe-2-high',
  'swe-2-max': 'swe-2-max',
  'swe-2.0-medium': 'swe-2-medium',
  'swe-2.0-high': 'swe-2-high',
  'swe-2.0-max': 'swe-2-max',
  // Dashed spelling of the same aliases. The unsuffixed `swe-2-0` row above was
  // the only dashed form present, so `swe-2-0-medium` / `-high` / `-max` missed
  // every map lookup: mapped:false on a live catalog (400 model_not_found) and
  // the swe-1-6-slow degrade on the frozen snapshot — same model, four spellings,
  // one of them silently free-tier. Targets are snapshot selectors.
  'swe-2-0-medium': 'swe-2-medium',
  'swe-2-0-high': 'swe-2-high',
  'swe-2-0-max': 'swe-2-max',
  'deepseek-v4': 'deepseek-v4',

  // GPT-5.6 family (live catalog 2026-07-09, docs.devin.ai price table). Luna is
  // the fastest/cheapest sibling. Bare, dash and compact forms (issue #244 verbatim
  // `gpt5.6-luna`) all default to -medium. Targets in the snapshot below.
  // `gpt5-6-luna` is the fourth spelling of the same name: compact prefix AND a
  // dashed minor. It was the one form missing, so it degraded to free while its
  // three siblings resolved.
  'gpt-5.6-luna': 'gpt-5-6-luna-medium',
  'gpt-5-6-luna': 'gpt-5-6-luna-medium',
  'gpt5.6-luna': 'gpt-5-6-luna-medium',
  'gpt5-6-luna': 'gpt-5-6-luna-medium',

  // Claude 5: fable/sonnet-5 already mapped above. Opus 5 (2026-07-23) added with
  // the family convention; bare `claude5` (issue #244) defaults to sonnet-5-medium.
  'claude-opus-5': 'claude-opus-5-medium',
  'claude5': 'claude-sonnet-5-medium',
  // Haiku 5.5 (live catalog 2026-10-18): the catalog advertises the family alias
  // `claude-haiku-5.5` across five effort tiers; bare forms default to -medium.
  'claude-haiku-5-5': 'claude-haiku-5-5-medium',
  'claude-haiku-5.5': 'claude-haiku-5-5-medium',
  // Live catalog 2026-10-18: new families observed in GetCliModelConfigs.
  // Bare family aliases default to -medium (or the highest tier when the
  // family has no -medium), matching the existing hand-curated convention.
  'claude-fable-5-1': 'claude-fable-5-1-medium',
  'claude-opus-5-5': 'claude-opus-5-5-medium',
  'claude-sonnet-5-5': 'claude-sonnet-5-5-medium',
  'deepseek-v4-1-flash': 'deepseek-v4-1-flash-high',
  'deepseek-v4-flash': 'deepseek-v4-flash-high',
  'deepseek-v4-pro': 'deepseek-v4-pro-high',
  'gemini-3-7-flash': 'gemini-3-7-flash-medium',
  'gemini-3.7-flash': 'gemini-3-7-flash-medium',
  'gemini-3-8-flash': 'gemini-3-8-flash-medium',
  'gemini-3.8-flash': 'gemini-3-8-flash-medium',
  'gemini-3-6-flash': 'gemini-3-6-flash-medium',
  'gemini-3.6-flash': 'gemini-3-6-flash-medium',
  'gemini-4-argon': 'gemini-4-argon-medium',
  'glm-5-3': 'glm-5-3-high',
  'glm-5-3-flash': 'glm-5-3-flash-high',
  'gpt-5-6-sol': 'gpt-5-6-sol-medium',
  'gpt-5.6-sol': 'gpt-5-6-sol-medium',
  'gpt-5-6-terra': 'gpt-5-6-terra-medium',
  'gpt-5.6-terra': 'gpt-5-6-terra-medium',
  'gpt-6-1-sol': 'gpt-6-1-sol-medium',
  'gpt-6.1-sol': 'gpt-6-1-sol-medium',
  'gpt-6-astra': 'gpt-6-astra-medium',
  'gpt-6-luna': 'gpt-6-luna-medium',
  'gpt-6-sol': 'gpt-6-sol-medium',
  // Bare family names. The catalog exposes only effort tiers for these two, so a
  // client sending the family name (the shape grok-4-6 / grok-4-7 already accept)
  // got a 400 while the siblings worked. Same convention as the rest of this map:
  // a bare family alias defaults to -medium. Targets are snapshot selectors.
  'grok-4-5': 'grok-4-5-medium',
  'grok-4-6': 'grok-4-6-medium',
  'grok-4.6': 'grok-4-6-medium',
  'grok-4-7': 'grok-4-7-medium',
  'grok-4.7': 'grok-4-7-medium',
  'inkling': 'inkling-medium',
  'kimi-k3': 'kimi-k3-high',
  'nemotron-3-ultra': 'nemotron-3-ultra-medium',
}));

// The set of selectors the live catalog actually exposes (committed snapshot,
// frame-verified 2026-06-30). A value written to GetChatMessageRequest #21 that
// is NOT in this set is refused by the upstream request validator as
// `invalid_argument` — surfaced as 400 REQUEST_REJECTED since the 2026-10-25
// mapping fix (before that it read as 503 UPSTREAM_INTERNAL and charged the
// internal-error breaker). Frame-proven 2026-07-04: bare "claude-opus-4-8"
// failed, "claude-opus-4-8-medium" 200'd.
// Used as a last-line existence guard on enum/dash-form passthrough. Ships under
// src/ (NOT test/) so it is present in the Docker image — the Dockerfile only
// COPYs src/, so a runtime read from test/ crashes the container on boot. Loaded
// with JSON.parse + readFileSync so this stays a zero-dep ESM module with no
// import assertion. The catalog-drift test reads this same file (single source).
const CATALOG_SELECTORS = new Set(
  JSON.parse(
    readFileSync(new URL('./data/devin-catalog-snapshot.json', import.meta.url), 'utf8'),
  ).models.map((m) => m.selector),
);

// The static last-resort fallback for an unmapped model name. It is the
// COLD-START answer — the one used while no live catalog has arrived — and it is
// never chosen once a synced catalog is authoritative. Live GetCliModelConfigs
// may omit it (#258: reporter's catalog had no swe-1-6-slow).
// Do NOT retarget this to swe-1-7 / glm-5-2 — those are drought-safe on Pro, not
// unpaid entitlement. STRICT_MODEL=1 already 400s unmapped names instead of
// silent-degrading here.
//
// Live evidence 2026-10-22 (Pro account): `swe-1-6-slow` is absent from the
// 240-selector Connect catalog AND from the account's 374 availableModels, and
// the upstream answers it with "Your daily usage quota has been exhausted".
// It is therefore only a DEGRADE TARGET, never a claim that it is callable.
//
// #258 residual: because it is dead on a live-catalog deployment, using it
// unconditionally turned every unmapped name into a fake quota 502. Resolution
// now takes a live free selector from the caller and falls back to this constant
// ONLY while no live catalog exists — see pickDegradeTarget. It is still exported
// because discovery/dashboard synthesize the row from it.
export const FREE_TIER_SELECTOR = 'swe-1-6-slow';
// Canonical selectors an UNPAID account is entitled to. This is ENTITLEMENT, not
// pricing: it is what the connect-namespace filter uses so a paid selector (e.g.
// a fable) is not routed to a free account, which the upstream rejects as
// permission_denied.
//
// Pricing moved out of this set (2026-10-22). "Free now" is derived from the
// account's live `modelCostTier`/`promoStatus` (models.js), then a zero rate in
// its paired credit table, and only falls back to this set when NEITHER live
// source has data anywhere — see auth.js getConnectSelectorPricing. Treating this
// set as the free list is what made the dashboard call `swe-1-6-slow` FREE while
// it 502'd, and mark the three actually-free swe-2 selectors as QUOTA.
export const FREE_REACHABLE_SELECTORS = new Set(['swe-1-6-slow']);

// Paid-plan selectors that do not consume the weekly quota bucket (#258,
// Pro probe 2026-08-23: weeklyPercent=0 still 200). Used ONLY by the drought
// gate. Do NOT add these to FREE_REACHABLE_SELECTORS — that set is unpaid-tier
// entitlement, and putting swe-1-7 / glm-5-2 there routes paid models onto
// free accounts. Canonical resolved selectors only; glm-5.1 is an alias of
// glm-5-2 and is omitted. swe-1-6-slow stays in FREE_REACHABLE, not here.
export const DROUGHT_SAFE_SELECTORS = new Set([
  'swe-1-7',
  'swe-1-7-medium',
  'glm-5-2',
]);

// ── Live catalog (audit 2026-07-12: snapshot staleness fix) ──────────────
// The committed CATALOG_SELECTORS snapshot is a point-in-time capture (105
// models, frame-verified 2026-06-30). Unlike the Cascade catalog — which
// self-heals via auth.js:fetchAndMergeModelCatalog → mergeCloudModels into
// MODELS — the DEVIN_CONNECT selector snapshot NEVER live-synced, so selectors
// the upstream added later (qwen-3 / glm-5 / kimi-k2.5 / deepseek-v3 /
// minimax-* — all present in a live account's availableModels, proven
// 2026-07-12) were absent from the snapshot and got 400'd by the strict gate
// (chat.js) as "not a valid model", despite being genuinely runnable.
//
// Fix: a runtime-populated live selector set, refreshed from GetCliModelConfigs
// (devin-connect-catalog.js:fetchCatalog) by auth.js on catalog sync. A NON-EMPTY
// live response is authoritative: keeping `snapshot ∪ live` after a successful
// sync advertises selectors omitted by upstream account-level restrictions. The
// snapshot is therefore only a cold-start / failed-sync fallback. Empty responses
// never replace a prior good live set, so making live authoritative does not turn
// a transient fetch failure into an empty catalog.
const _liveSelectors = new Set();
// True while at least one active account (or an explicit env-token path) still
// lacks a last-known-good catalog. In that mixed state the frozen snapshot stays
// available for that fallback contributor, while accounts with live rows remain
// constrained by auth.js's per-account gate.
let _snapshotFallbackEnabled = true;
// Full decoded catalog rows ({ selector, label, provider, alias, ... }) from the
// last good sync. Kept alongside _liveSelectors so /v1/models can synthesize
// entries for live-only selectors that aren't in the hardcoded MODELS table
// (audit 2026-07-12: the 37 upstream-added selectors — gpt-5-6-*/grok-4-5-*/
// nemotron — run fine at /v1/chat/completions but were missing from /v1/models,
// so Codex/clients couldn't discover them). Empty until the first sync.
let _liveCatalog = [];

/** True when at least one live catalog sync has populated the live set. */
export function hasLiveCatalog() { return _liveSelectors.size > 0; }

/** The full decoded live catalog rows from the last good sync (may be []). */
export function getLiveCatalog() { return _liveCatalog; }

/**
 * The live catalog's EXPLICIT image capability for a resolved selector.
 *
 * Three-state on purpose, matching `disabled` (#4): `true`/`false` only when the
 * upstream actually sent field #5 for this selector; `null` when the catalog is
 * empty (cold start / failed sync), the selector is absent, or the row stayed
 * silent. A caller must never read `null` as "no" — the same rule that keeps
 * isModelDisabledUpstream from refusing statically-catalogued models.
 *
 * This is the request-path twin of the `supports_images` bit that
 * handlers/models.js advertises from these same rows.
 */
export function getConnectSelectorImageCapability(selector) {
  const key = String(selector || '').trim();
  if (!key) return null;
  for (const row of _liveCatalog) {
    if (row?.selector !== key) continue;
    return typeof row.supportsImages === 'boolean' ? row.supportsImages : null;
  }
  return null;
}

/** Frozen cold-start selectors (the committed snapshot), for dashboard diffing. */
export function getSnapshotSelectors() { return [...CATALOG_SELECTORS]; }

/**
 * Replace the live DEVIN_CONNECT selector set from a fresh GetCliModelConfigs
 * fetch. Called by auth.js after fetchCatalog(). Accepts the decoded catalog
 * (array of { selector, alias? }) or a plain array/Set of selector strings.
 * Only canonical selectors enter the authoritative set; family aliases remain
 * governed by SELECTOR_MAP so an upstream-advertised shorthand is never sent raw
 * when field #21 requires a full selector. No-op on empty/garbage input (keeps
 * the prior live set).
 */
export function setLiveCatalogSelectors(catalog, { includeSnapshotFallback = false } = {}) {
  const items = Array.isArray(catalog) ? catalog
    : (catalog instanceof Set ? [...catalog] : []);
  if (!items.length) return;
  const next = new Set();
  const rowsBySelector = new Map();
  for (const it of items) {
    if (typeof it === 'string') {
      const selector = it.trim();
      if (!selector) continue;
      next.add(selector);
      if (!rowsBySelector.has(selector)) rowsBySelector.set(selector, { selector });
      continue;
    }
    if (it && typeof it === 'object') {
      // ONLY the canonical `selector` (the full, upstream-accepted form) goes into
      // the live existence set. The catalog's `alias` is a FAMILY shortcut
      // (e.g. selector "gpt-5-6-sol-medium" → alias "gpt-5.6-sol"; and multiple
      // effort tiers of one family SHARE one alias — claude-opus-4-7-{low,medium,
      // high,max} all alias to "claude-opus-4.7"). The upstream does NOT accept an
      // alias as a model name — writing a bare family alias to GetChatMessageRequest
      // #21 trips the request validator (`invalid_argument` → 400 REQUEST_REJECTED;
      // before the 2026-10-25 mapping fix it read as UPSTREAM_INTERNAL and burned
      // the account). Frame-proven: only the full "-medium" form 200s. Folding
      // aliases in here made resolveConnectSelector
      // return mapped:true for a family alias not covered by SELECTOR_MAP (e.g.
      // "gpt-5.6-sol") and pass that bad form straight through. Aliases are handled
      // by the hand-maintained SELECTOR_MAP (which resolves them to a real selector);
      // an alias the map doesn't know must fail closed, not pass through raw.
      // (ultracode review 2026-07-12; real-account confirmed gpt-5.6-sol regression)
      const selector = typeof it.selector === 'string' ? it.selector.trim() : '';
      if (!selector) continue;
      next.add(selector);
      if (!rowsBySelector.has(selector)) rowsBySelector.set(selector, { ...it, selector });
    }
  }
  if (!next.size) return; // never blank out a good set on a bad fetch
  _liveSelectors.clear();
  for (const s of next) _liveSelectors.add(s);
  _snapshotFallbackEnabled = includeSnapshotFallback === true;
  // Retain normalized rows so every consumer sees the same canonical selector
  // strings as the existence set. String-only test seams become minimal rows
  // instead of leaving stale metadata from an earlier object catalog behind.
  _liveCatalog = [...rowsBySelector.values()];
}

/**
 * Drop the live catalog entirely, leaving only the frozen snapshot.
 *
 * Distinct from setLiveCatalogSelectors([]), which deliberately IGNORES empty
 * input so a failed or truncated fetch can't blank out a good set. This is the
 * explicit "there is genuinely nothing left" path — used when the last account
 * contributing to the pool union goes away, where keeping the departed account's
 * selectors would advertise models nothing in the pool can reach.
 */
export function clearLiveCatalogSelectors() {
  _liveSelectors.clear();
  _liveCatalog = [];
  _snapshotFallbackEnabled = true;
}

// Synthetic selectors do not appear in GetCliModelConfigs but remain valid
// routing targets. Keep this list deliberately tiny: everything else must come
// from the authoritative live catalog once one has been fetched.
const CATALOG_INDEPENDENT_SELECTORS = new Set([
  ...FREE_REACHABLE_SELECTORS,
  'subagent-default',
]);

/**
 * Does the currently authoritative Connect catalog contain this selector?
 *
 * Before the first successful live sync, fall back to the frozen snapshot so a
 * cold-start or transient catalog failure stays usable. Once live data exists,
 * use it exclusively so removed snapshot selectors are not advertised or routed.
 */
export function isKnownConnectSelector(name, { allowSnapshotFallback = false } = {}) {
  if (CATALOG_INDEPENDENT_SELECTORS.has(name)) return true;
  if (_liveSelectors.has(name)) return true;
  const envTokenFallback = !!(
    process.env.DEVIN_CONNECT_TOKEN || process.env.WINDSURF_API_KEY
  );
  return (
    _liveSelectors.size === 0
    || _snapshotFallbackEnabled
    || allowSnapshotFallback
    || envTokenFallback
  ) && CATALOG_SELECTORS.has(name);
}

/** True for valid synthetic selectors that an account catalog never enumerates. */
export function isConnectSelectorCatalogIndependent(name) {
  return CATALOG_INDEPENDENT_SELECTORS.has(name);
}

/**
 * Pick the selector an unmapped name degrades to, or null when there is none.
 *
 * The rule is EVIDENCE, not a fixed name:
 *
 *   - A synced catalog (with no snapshot fallback in play) is authoritative:
 *     only a selector it lists can be called, so only a live free selector from
 *     `freeFallback` is eligible. `swe-1-6-slow` is the proof this matters — it
 *     stayed the degrade target after the live catalog dropped it, and every
 *     unmapped name then surfaced as a fake "daily usage quota has been
 *     exhausted" 502 (#258 residual). Nothing eligible → null, and the caller
 *     answers `model_not_found` instead of routing.
 *   - With no live evidence (cold start, or a pool where some contributor still
 *     lacks a catalog) the documented static floor applies, because nothing has
 *     contradicted it yet. A caller-supplied free selector still outranks it —
 *     the caller already restricted its list to what the pool can reach.
 *
 * Note this deliberately does NOT consult `isKnownConnectSelector`: that
 * predicate answers true for every CATALOG_INDEPENDENT selector (which includes
 * FREE_REACHABLE_SELECTORS) because a CALLER may legitimately name one. That is
 * the right answer for "did the client name a real model" and the wrong one for
 * "should we CHOOSE this as a degrade target".
 */
function pickDegradeTarget({ freeFallback } = {}) {
  const liveAuthoritative = _liveSelectors.size > 0 && !_snapshotFallbackEnabled;
  for (const candidate of freeFallback || []) {
    const name = typeof candidate === 'string' ? candidate.trim() : '';
    if (!name) continue;
    if (!liveAuthoritative || _liveSelectors.has(name)) return name;
  }
  return liveAuthoritative ? null : FREE_TIER_SELECTOR;
}

/**
 * Resolve a client-supplied model name to an upstream DEVIN_CONNECT selector.
 * Normalizes case and dot/dash variations. An unmapped name degrades to a free
 * selector so an enabled DEVIN_CONNECT deploy does not hard-fail on a stale
 * alias — but ONLY to one that is callable (see pickDegradeTarget); when no
 * such selector exists the result carries `selector: null` and the request must
 * be answered with `model_not_found` rather than routed to a dead name.
 *
 * @param {string} model
 * @param {object} [opts]
 * @param {boolean} [opts.warnOnFallback=true] set false for read-only catalog
 *   probes; a later real request will still emit the one-time downgrade warning
 * @param {boolean} [opts.allowSnapshotFallback=false] keep frozen selectors
 *   resolvable for an explicit env-token/empty-pool fallback contributor
 * @param {string[]} [opts.freeFallback=[]] live free selectors, most preferred
 *   first. Callers that hold live pricing data (auth.js) pass it so the degrade
 *   target tracks what the upstream actually serves; omitted, the static floor
 *   is used.
 * @returns {{ selector: string|null, mapped: boolean }} `selector` is null only
 *   when the name is unmapped AND no callable degrade target exists — the caller
 *   must then answer model_not_found instead of routing.
 */
export function resolveConnectSelector(model, {
  warnOnFallback = true,
  allowSnapshotFallback = false,
  freeFallback = [],
} = {}) {
  const raw = String(model || '').trim();
  // `warnName` is null for an omitted model: that is the documented default-model
  // path, not a caller naming a model we could not find, so it does not warn.
  const degrade = (warnName) => {
    const target = pickDegradeTarget({ freeFallback });
    if (!target) return { selector: null, mapped: false };
    if (warnOnFallback && warnName) warnDegradeOnce(warnName, target);
    return { selector: target, mapped: false };
  };
  const hit = (selector) => ({ selector, mapped: true });

  if (!raw) return degrade(null);

  // A hand-maintained alias is valid only while its TARGET exists in the
  // authoritative catalog. Otherwise a stale map entry can keep a removed model
  // routable forever even after the live sync proved it is gone.
  const directTarget = SELECTOR_MAP.get(raw);
  if (directTarget && isKnownConnectSelector(directTarget, { allowSnapshotFallback })) {
    return hit(directTarget);
  }

  // Normalize: lowercase, strip a leading provider prefix some clients prepend
  // (e.g. "anthropic/claude-..."), then collapse dots to dashes.
  //
  // The prefix segment may contain digits and hyphens: real provider ids include
  // `x-ai` and `z-ai`, and the old `/^[a-z]+\//` could not match them, so every
  // `x-ai/...` spelling missed every lookup and failed model_not_found against a
  // live catalog (313 probed rows across 48 families). Strip exactly ONE segment:
  // in `x/y/glm-5.1` the second segment is part of the name. pickToolDialect
  // applies this same rule before its family checks (verifier P2-2) — keep them
  // identical.
  const stripped = raw.toLowerCase().replace(/^[a-z0-9][a-z0-9-]*\//, '');
  // Compact version spellings (`gpt5.5`, `gpt5.4-mini`, `gemini3.5-flash`) are
  // the same model written without the separator after the family name, but the
  // catalog and the alias map only carry the separated forms, so they used to
  // resolve to nothing while their dotted siblings resolved (S4: 10 families).
  // Split at the version separator BEFORE the map lookup.
  //
  // The `.` is REQUIRED. Without it the rule also rewrites the family shorthand
  // `claude5` into the nonexistent `claude-5`, silently degrading an alias that
  // works today (probed: it is the only SELECTOR_MAP key the wide rule would
  // break). A compact spelling without a minor version is a family name, not a
  // version, so it must keep its existing alias.
  const compact = stripped.replace(/^(gpt|claude|gemini)(\d)\./, '$1-$2.');
  const norm = compact.replace(/\./g, '-');
  const normalizedTarget = SELECTOR_MAP.get(norm);
  if (normalizedTarget && isKnownConnectSelector(normalizedTarget, { allowSnapshotFallback })) {
    return hit(normalizedTarget);
  }
  // A normalized dash-form that IS a real catalog selector (e.g. client sent the
  // dotted "gpt-5.5-medium" → norm "gpt-5-5-medium" which the catalog exposes but
  // the alias map doesn't list). Without this, a valid selector written with dots
  // silently degraded to the free tier. Checked after the map so an alias still
  // wins, before the free-tier fallback.
  if (isKnownConnectSelector(norm, { allowSnapshotFallback })) {
    return hit(norm);
  }

  // Enum-form passthrough — ONLY when the catalog actually exposes it. Test the
  // SHAPE on the normalized key and look the UPPERCASE form up: a client may copy
  // the enum verbatim (MODEL_GPT_5_2_LOW), lowercase it, or prepend a provider
  // prefix, and the old raw-string test let only the first of those through
  // (S5: 96 probed rows). A blind MODEL_* passthrough is what re-introduces a
  // request-validator rejection (`invalid_argument` → 400 REQUEST_REJECTED) on
  // drift, so the existence check stays.
  const enumForm = norm.toUpperCase();
  if (/^MODEL_[A-Z0-9_]+$/.test(enumForm)
    && isKnownConnectSelector(enumForm, { allowSnapshotFallback })) {
    return hit(enumForm);
  }

  // A verbatim dash-form selector that IS in the authoritative catalog but is
  // missing from the alias map (e.g. a selector the upstream added after the
  // frozen snapshot — qwen-3/glm-5/etc.) should still go through rather than
  // silently degrade a paid request to free.
  if (isKnownConnectSelector(raw, { allowSnapshotFallback })) {
    return hit(raw);
  }

  // Unmapped: degrade to a callable free selector, and make it OBSERVABLE
  // (one-time per distinct model) so a caller ignoring mapped:false still gets
  // an operator signal that a paid model was downgraded to free. `selector: null`
  // is the distinct "there is nothing to downgrade to" answer, which the handler
  // must report as model_not_found (#258 residual).
  return degrade(raw);
}

// Tracks model names we've already warned about so the degrade signal fires
// once per distinct name rather than on every request (avoids log flooding).
//
// The name is CALLER-CONTROLLED and this record is process-lifetime, and the
// resolver runs BEFORE the strict-model 400 in handlers/chat.js — so a rejected
// request still left its raw string here until restart: one unbounded entry per
// distinct junk name (audit 2026-09-22, D-SCALE-001, reproduced — 99 rejected
// requests retained 99 raw names). Two bounds close that: the key is a SHA-256
// digest (fixed 64 chars, so retained BYTES are bounded as well as cardinality)
// and the set is a FIFO cache — a duplicate hit does NOT refresh its position, so
// once the cap is exceeded the oldest insertion is evicted and may warn again.
// The warning text is unchanged: it still names the model for the operator, that
// string is just no longer the stored key.
const DEGRADE_WARNED_MAX = 1024;
const degradeWarned = new Set();

/** Emit the one-time unmapped-model warning, keeping its record bounded (FIFO). */
function warnDegradeOnce(raw, target) {
  const key = createHash('sha256').update(raw).digest('hex');
  if (degradeWarned.has(key)) return;
  degradeWarned.add(key);
  // Set iteration order is insertion order, so the first key is the oldest.
  if (degradeWarned.size > DEGRADE_WARNED_MAX) {
    degradeWarned.delete(degradeWarned.values().next().value);
  }
  // Name the selector the request ACTUALLY runs on: it is live-derived whenever
  // the caller passed freeFallback, so hardcoding FREE_TIER_SELECTOR here would
  // point the operator at a selector this request never used (#258 residual).
  log.warn(
    `[devin-connect] unmapped model "${raw}" not in catalog — degrading to `
    + `${target} (paid request downgraded to free tier)`,
  );
}

export const __testing = { SELECTOR_MAP, CATALOG_SELECTORS, degradeWarned, _liveSelectors };