Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -827,8 +827,9 @@ const MODEL_CATALOG_CONFIRM_RETRY_MS = 30_000;
// the live set back down. Hence per-account rows here, unioned before every write.
//
// Existence and entitlement are deliberately different questions. This union
// answers "does the upstream have this selector at all" — the pool-wide view. Who
// may CALL it stays per-account (isConnectSelectorAllowedForAccount → tier bucket).
// answers "does at least one active account's live catalog contain this selector?"
// — the pool-wide discovery view. Who may CALL it stays per-account: the tier
// bucket and that account's own catalog must both allow it.
const _connectCatalogRowsByAccount = new Map(); // account id → decoded catalog rows
const _connectCatalogSyncedKeys = new Map(); // account id → apiKey already synced
const _connectCatalogSyncedAt = new Map(); // account id → successful refresh timestamp
Expand Down Expand Up @@ -1409,7 +1410,20 @@ export function isConnectSelectorAllowedForAccount(account, selector) {
// Paid selector: require a paid bucket. 'unknown' (unprobed new account) is
// allowed — it self-heals to 'free' after a probe, then gets blocked here on
// the next request, matching MODEL_TIER_ACCESS.unknown's optimistic policy.
return bucket === 'pro' || bucket === 'unknown';
if (bucket !== 'pro' && bucket !== 'unknown') return false;

// A successful GetCliModelConfigs response is authoritative for THIS account.
// Paid tier alone is not proof that every selector in the frozen snapshot still
// exists. Mixed pools expose the union, then this check keeps routing within the
// selected account's own contribution. If this account has never produced a
// non-empty catalog, retain the existing tier-based fail-open so a cold start or
// transient catalog failure does not take the account offline.
const rows = _connectCatalogRowsByAccount.get(account.id);
if (!Array.isArray(rows) || rows.length === 0) return true;
return rows.some((row) => {
const candidate = typeof row === 'string' ? row : row?.selector;
return typeof candidate === 'string' && candidate.trim() === selector;
});
}

// True if at least one active account is entitled to this connect selector.
Expand Down
36 changes: 32 additions & 4 deletions src/dashboard/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { getLogs, subscribeToLogs, unsubscribeFromLogs } from './logger.js';
import { getProxyConfig, getProxyConfigMasked, setGlobalProxy, setAccountProxy, removeProxy, getEffectiveProxy } from './proxy-config.js';
import { MODELS, MODEL_TIER_ACCESS as _TIER_TABLE, getTierModels as _getTierModels, filterModelKeysByCloudCatalog } from '../models.js';
import { buildConnectReachability } from '../handlers/models.js';
import { FREE_REACHABLE_SELECTORS } from '../devin-connect-models.js';
import { FREE_REACHABLE_SELECTORS, getLiveCatalog } from '../devin-connect-models.js';
import { windsurfLogin, refreshFirebaseToken, reRegisterWithCodeium } from './windsurf-login.js';
import { getModelAccessConfig, setModelAccessMode, setModelAccessList, addModelToList, removeModelFromList, setDefaultModel } from './model-access.js';
import { checkMessageRateLimit } from '../windsurf-api.js';
Expand Down Expand Up @@ -1903,6 +1903,9 @@ export async function handleDashboardApi(method, subpath, body, req, res) {
// usable rate table, which is what lets `currentlyFree` below distinguish "costs quota"
// from "we do not know yet".
const freeSet = getCurrentlyFreeConnectSelectors();
const connectCurrentlyFree = (selector) => (!selector ? null
: isConnectSelectorCurrentlyFree(selector) ? true
: (freeSet === null ? null : false));
const models = filterModelKeysByCloudCatalog().map((id) => {
const info = MODELS[id];
const { reachable, selector } = isReachable(id);
Expand Down Expand Up @@ -1931,9 +1934,7 @@ export async function handleDashboardApi(method, subpath, body, req, res) {
// Hence the explicit freeSet null-check: auth.js:304 returns null for "no table
// anywhere" precisely so callers do not have to guess, and flattening it here would
// throw away the one thing that function went out of its way to preserve.
currentlyFree: !selector ? null
: isConnectSelectorCurrentlyFree(selector) ? true
: (freeSet === null ? null : false),
currentlyFree: connectCurrentlyFree(selector),
};
});
// Selectors that ARE serveable but have no MODELS row (`swe-1-6-slow` is in neither the
Expand All @@ -1949,6 +1950,33 @@ export async function handleDashboardApi(method, subpath, body, req, res) {
// test/dashboard-models-connect-parity.test.js.
if (getBackendSwitch('devinConnect')) {
const seen = new Set(models.map((m) => m.id));
const representedSelectors = new Set(models
.map((m) => m.connectSelector)
.filter(Boolean));

// /v1/models has a second producer for selectors present in the live Connect
// catalog but absent from the shared MODELS table. Mirror it here; otherwise
// the API advertises live-only selectors that the Dashboard cannot display,
// inspect for pricing, or manage. Keep rows annotated rather than filtered:
// a free account's catalog still lists paid models, and the operator needs to
// see those as unreachable rather than have them disappear.
for (const row of getLiveCatalog()) {
const id = (typeof row === 'string' ? row : row?.selector)?.trim();
if (!id || seen.has(id) || representedSelectors.has(id)) continue;
seen.add(id);
const { reachable, selector } = isReachable(id);
if (selector) representedSelectors.add(selector);
models.push({
id,
name: row.label || id,
provider: row.provider || 'windsurf',
credit: null,
reachable,
connectSelector: selector,
currentlyFree: connectCurrentlyFree(selector),
});
}

for (const selector of FREE_REACHABLE_SELECTORS) {
if (seen.has(selector)) continue;
seen.add(selector);
Expand Down
91 changes: 65 additions & 26 deletions src/devin-connect-models.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,12 @@ export const FREE_REACHABLE_SELECTORS = new Set(['swe-1-6-slow']);
// (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. The
// existence checks below treat "snapshot ∪ live" as the source of truth — the
// snapshot degrades to a cold-start fallback + the catalog-drift test baseline,
// exactly the single-source-of-truth principle converged on cross-project.
// Empty until the first sync (cold start falls back to snapshot alone).
// (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();
// Full decoded catalog rows ({ selector, label, provider, alias, ... }) from the
// last good sync. Kept alongside _liveSelectors so /v1/models can synthesize
Expand Down Expand Up @@ -205,8 +206,15 @@ export function setLiveCatalogSelectors(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') { if (it.trim()) next.add(it.trim()); continue; }
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
Expand All @@ -221,16 +229,19 @@ export function setLiveCatalogSelectors(catalog) {
// 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)
if (typeof it.selector === 'string' && it.selector.trim()) next.add(it.selector.trim());
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);
// Retain the full rows too (only when we were handed decoded objects, not a
// bare string/Set) so /v1/models can synthesize live-only entries.
const rows = items.filter((it) => it && typeof it === 'object' && typeof it.selector === 'string' && it.selector.trim());
if (rows.length) _liveCatalog = rows;
// 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()];
}

/**
Expand All @@ -247,9 +258,26 @@ export function clearLiveCatalogSelectors() {
_liveCatalog = [];
}

/** A selector exists if the frozen snapshot OR the live catalog knows it. */
function selectorExists(name) {
return CATALOG_SELECTORS.has(name) || _liveSelectors.has(name);
// 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 ALWAYS_KNOWN_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) {
if (ALWAYS_KNOWN_SELECTORS.has(name)) return true;
return _liveSelectors.size > 0
? _liveSelectors.has(name)
: CATALOG_SELECTORS.has(name);
}

/**
Expand All @@ -259,43 +287,54 @@ function selectorExists(name) {
* unmapped alias — it degrades to the one selector that always works.
*
* @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
* @returns {{ selector: string, mapped: boolean }}
*/
export function resolveConnectSelector(model) {
export function resolveConnectSelector(model, { warnOnFallback = true } = {}) {
const raw = String(model || '').trim();
if (!raw) return { selector: FREE_TIER_SELECTOR, mapped: false };

// Direct hit (covers both dash-form and enum-form selectors passed verbatim).
if (SELECTOR_MAP.has(raw)) return { selector: SELECTOR_MAP.get(raw), mapped: true };
// 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)) {
return { selector: directTarget, mapped: true };
}

// Normalize: lowercase, collapse dots to dashes, strip a leading provider
// prefix some clients prepend (e.g. "anthropic/claude-...").
const norm = raw.toLowerCase().replace(/^[a-z]+\//, '').replace(/\./g, '-');
if (SELECTOR_MAP.has(norm)) return { selector: SELECTOR_MAP.get(norm), mapped: true };
const normalizedTarget = SELECTOR_MAP.get(norm);
if (normalizedTarget && isKnownConnectSelector(normalizedTarget)) {
return { selector: normalizedTarget, mapped: true };
}
// 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 (selectorExists(norm)) return { selector: norm, mapped: true };
if (isKnownConnectSelector(norm)) return { selector: norm, mapped: true };

// Enum-form passthrough — ONLY when the catalog actually exposes it. A blind
// MODEL_* passthrough is what re-introduces UPSTREAM_INTERNAL on drift: any
// bogus MODEL_DOES_NOT_EXIST would otherwise be written raw to #21.
if (/^MODEL_[A-Z0-9_]+$/.test(raw) && selectorExists(raw)) {
if (/^MODEL_[A-Z0-9_]+$/.test(raw) && isKnownConnectSelector(raw)) {
return { selector: raw, mapped: true };
}

// A verbatim dash-form selector that IS in the catalog (snapshot ∪ live) but
// missing from the alias map (e.g. a lowercased/prefixed valid enum, or 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 (selectorExists(raw)) return { selector: raw, mapped: true };
// 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)) return { selector: raw, mapped: true };

// Unmapped: degrade to the always-available free selector, but 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.
if (!degradeWarned.has(raw)) {
if (warnOnFallback && !degradeWarned.has(raw)) {
degradeWarned.add(raw);
log.warn(
`[devin-connect] unmapped model "${raw}" not in catalog — degrading to `
Expand Down
Loading