/** * 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 };