Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
let _connectCatalogSyncPromise = null;
Expand Down Expand Up @@ -1368,7 +1369,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 @@ -1905,6 +1905,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 @@ -1933,9 +1936,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 @@ -1951,6 +1952,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 = row?.selector;
if (!id || seen.has(id) || representedSelectors.has(id)) continue;
seen.add(id);
const { reachable, selector } = isReachable(id);
Comment thread
andya1lan marked this conversation as resolved.
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
69 changes: 49 additions & 20 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 @@ -247,9 +248,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 +277,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
63 changes: 35 additions & 28 deletions src/handlers/models.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { listModels } from '../models.js';
import { resolveConnectSelector, getLiveCatalog, FREE_REACHABLE_SELECTORS, __testing } from '../devin-connect-models.js';
import {
resolveConnectSelector, getLiveCatalog, FREE_REACHABLE_SELECTORS,
} from '../devin-connect-models.js';
import { getBackendSwitch } from '../runtime-config.js';
import { hasConnectEntitledAccount, getAccountCount } from '../auth.js';

Expand Down Expand Up @@ -51,31 +53,24 @@ export function buildConnectReachability(env = process.env) {
if (!getBackendSwitch('devinConnect', effectiveEnv)) {
return () => ({ reachable: true, selector: null });
}
const known = (selector) => __testing.CATALOG_SELECTORS.has(selector) || __testing._liveSelectors.has(selector);
const skipEntitlement = shouldSkipEntitlementFilter(effectiveEnv, getAccountCount().total);
const entitled = (selector) => skipEntitlement || hasConnectEntitledAccount(selector);
// NOTE on what is deliberately NOT here: a FREE_REACHABLE_SELECTORS short-circuit.
//
// It looks like it belongs — `swe-1-6-slow` is callable by any account and is absent from
// both the snapshot and the live catalog, so `known()` answers false for it. But every
// caller passes a MODELS-derived id (`handleModels` passes `m._windsurf_id`, the Dashboard
// passes the MODELS key), and the free selector is NEITHER a MODELS key nor any entry's
// `_windsurf_id` — measured against all 163 entries. So the branch never executed. It was
// written, then mutation-verified to be unreachable: deleting it failed zero assertions.
//
// The floor is real, it just does not live here — both views SYNTHESIZE the free selector
// as a row (/v1/models' third producer, and the Dashboard route's equivalent), which is
// where it actually works. v3.9.13 shipped a defect of exactly this shape: an exported
// helper with no production caller whose mutation guard was watching unreachable code.
//
// And re-testing the RESOLVED selector instead would be worse than dead — with
// STRICT_MODEL=0 an unmapped paid name degrades to `swe-1-6-slow`, so testing after
// resolution reports `claude-4-sonnet` as reachable on a free-only pool, which is exactly
// the lie #234 is about. `connect-discovery-rebuild.test.js` caught that on the first run
// when this was first written the wrong way round.
// Do not short-circuit on the RESOLVED free selector. With STRICT_MODEL=0 an
// unmapped paid name resolves to `swe-1-6-slow` with mapped:false; treating the
// selector alone as reachable would re-advertise every unsupported paid name.
// The real free floor is synthesized by both callers after this predicate runs.
return (windsurfId) => {
const { selector, mapped } = resolveConnectSelector(windsurfId);
return { reachable: !!(mapped && known(selector) && entitled(selector)), selector: mapped ? selector : null };
// Discovery probes every MODELS row, including intentionally unsupported
// Cascade-only names. They are not paid requests and must not consume the
// resolver's one-time downgrade warning; a later real chat request still will.
const { selector, mapped } = resolveConnectSelector(windsurfId, { warnOnFallback: false });
return {
// resolveConnectSelector already validates aliases and direct selectors
// against the authoritative catalog. Keep entitlement as the second,
// independent gate; re-checking existence here would duplicate policy.
reachable: !!(mapped && entitled(selector)),
selector: mapped ? selector : null,
};
};
}

Expand All @@ -88,16 +83,27 @@ export function handleModels(env = process.env) {
if (getBackendSwitch('devinConnect', effectiveEnv)) {
// Row producer #1: the MODELS table, filtered to what this deployment can serve.
//
// The rule (existence = snapshot ∪ live, plus the per-account entitlement check, plus
// the FREE_REACHABLE floor) now lives in buildConnectReachability because the Dashboard
// needs the identical answer. Existence alone used to be the only test here, so a
// The rule (existence = authoritative live catalog, with snapshot as cold-start
// fallback, plus per-account entitlement and the FREE_REACHABLE floor) now lives in
// buildConnectReachability because the Dashboard needs the identical answer. Existence
// alone used to be the only test here, so a
// free-only pool advertised every paid selector the upstream publishes and the client
// got a 403 at chat (#234 / #231 in the connect namespace). #232 fixed that for the
// Cascade namespace, but its filters early-return unfiltered when devinConnect is on
// (models.js isModelAllowedByCloudCatalog / filterModelKeysByCloudCatalog), which is
// correct as a namespace boundary and is why the check has to be redone here.
const isReachable = buildConnectReachability(effectiveEnv);
data = data.filter((m) => isReachable(m._windsurf_id).reachable);
// Discovery is a selector catalog, not an alias catalog. Several public names can
// resolve to the same upstream selector (for example `claude-opus-4.6` and
// `claude-opus-4-6`). Keep the first stable client-facing name and suppress the
// rest, otherwise one entitled upstream model appears two or three times.
const representedSelectors = new Set();
data = data.filter((m) => {
const resolved = isReachable(m._windsurf_id);
if (!resolved.reachable || representedSelectors.has(resolved.selector)) return false;
representedSelectors.add(resolved.selector);
return true;
});
// Producers #2 and #3 below are keyed by SELECTOR, not by a MODELS id, so they cannot
// go through isReachable — it resolves its argument through resolveConnectSelector.
// They keep the entitlement check directly.
Expand All @@ -116,9 +122,10 @@ export function handleModels(env = process.env) {
// selector (measured: 86 rows survived a filter applied to producer #1 alone).
for (const row of getLiveCatalog()) {
const id = row.selector;
if (!id || seen.has(id)) continue;
if (!id || seen.has(id) || representedSelectors.has(id)) continue;
if (!entitled(id)) continue;
Comment thread
andya1lan marked this conversation as resolved.
Outdated
seen.add(id);
representedSelectors.add(id);
data.push({
id,
object: 'model',
Expand Down
22 changes: 22 additions & 0 deletions test/connect-catalog-delatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import assert from 'node:assert/strict';
import {
__resetModelCatalogState, __setModelCatalogDeps, __waitForModelCatalogSync,
addAccountByKey, removeAccount, getAccountInternal, setAccountStatus,
isConnectSelectorAllowedForAccount,
} from '../src/auth.js';

const created = [];
Expand Down Expand Up @@ -108,6 +109,27 @@ describe('connect catalog de-latch (#234)', () => {
'the resolver must hold the union of both accounts, not just the last one');
});

it('keeps paid-account routing inside that account\'s own live catalog', async () => {
installDeps({
perAccount: {
'sk-opus-acct': [{ selector: 'claude-opus-4-8-medium' }],
'sk-gpt-acct': [{ selector: 'gpt-5-5-low' }],
},
});

const opus = mk('sk-opus-acct', 'pro');
await __waitForModelCatalogSync();
const gpt = mk('sk-gpt-acct', 'pro');
await __waitForModelCatalogSync();

assert.equal(isConnectSelectorAllowedForAccount(opus, 'claude-opus-4-8-medium'), true);
assert.equal(isConnectSelectorAllowedForAccount(opus, 'gpt-5-5-low'), false,
'a paid tier is not permission to route a selector absent from this account catalog');
assert.equal(isConnectSelectorAllowedForAccount(gpt, 'gpt-5-5-low'), true);
assert.equal(isConnectSelectorAllowedForAccount(gpt, 'claude-opus-4-8-medium'), false,
'mixed-pool union must not erase per-account routing boundaries');
});

it('does not let an empty response shrink the union', async () => {
// Same asymmetry as the Cascade empty-catalog guard: an empty response is no
// data, not "this account reaches nothing".
Expand Down
Loading