diff --git a/.env.example b/.env.example index 88da1af8..9c67864b 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,14 @@ API_KEY= # bare-source installs so accounts.json / stats.json / logs don't scatter there. DATA_DIR= +# On the Cascade transport, after GetCascadeModelConfigs returns usable +# per-account catalogs, pool model lists show their union while routing enforces +# the selected account's catalog. DEVIN_CONNECT uses its separate selector +# catalog and is not filtered by this switch. +# Missing, empty, or failed catalog fetches fail open. Set this to 1 only if you +# need the legacy behavior that exposes and routes the full static catalog. +# WINDSURFAPI_IGNORE_CLOUD_FILTER=1 + # ========== Reverse proxy / load balancer ========== # Only honour X-Forwarded-For when a trusted proxy sits in front (e.g. the # bundled docker-compose nginx LB, or openresty/Caddy). Without this every diff --git a/README.en.md b/README.en.md index bfea169e..ec7f6fc3 100644 --- a/README.en.md +++ b/README.en.md @@ -284,6 +284,7 @@ In your client's settings for **Custom OpenAI Compatible**: | `DEFAULT_MODEL` | `claude-4.5-sonnet-thinking` | The model to use if `model` is not specified. | | `MAX_TOKENS` | `8192` | Default maximum number of response tokens. | | `LOG_LEVEL` | `info` | debug / info / warn / error | +| `WINDSURFAPI_IGNORE_CLOUD_FILTER` | `0` | On the Cascade transport, after per-account cloud catalogs sync, pool listings show their union and routing enforces each selected account's catalog. Set to `1` to restore the full static catalog. Missing, empty, or failed catalog syncs fail open. `DEVIN_CONNECT` uses its separate selector catalog. | | `LS_BINARY_PATH` | `/opt/windsurf/language_server_linux_x64` | Path to the LS binary. | | `LS_PORT` | `42100` | LS gRPC port. | | `LS_DATA_DIR` | Linux: `/opt/windsurf/data`; macOS: `~/.windsurf/data` | Per-proxy LS data directory root. | @@ -332,7 +333,7 @@ Open `http://YOUR_IP:3003/dashboard`: ## Supported Models -100+ static models in the main catalog plus dynamic cloud-side models added at startup via `mergeCloudModels`. Full list: `GET /v1/models`, or browse the [GitHub Pages model catalog](https://dwgx.github.io/WindsurfAPI/#models) (auto-generated from `src/models.js`). +100+ static models in the main catalog plus dynamic cloud-side models added at startup via `mergeCloudModels`. On the Cascade transport, after per-account cloud catalogs sync, `GET /v1/models` and the Dashboard show the union available across active accounts, while routing applies the selected account's own catalog. `DEVIN_CONNECT` remains governed by its separate selector catalog. The full static catalog remains available on the [GitHub Pages model catalog](https://dwgx.github.io/WindsurfAPI/#models) (auto-generated from `src/models.js`).
Claude (Anthropic) — 21 models diff --git a/README.md b/README.md index c0e11d37..fc131210 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ curl http://localhost:3003/v1/messages \ | `DEFAULT_MODEL` | `claude-4.5-sonnet-thinking` | 不传 model 用哪个 | | `MAX_TOKENS` | `8192` | 默认最大回复 token 数 | | `LOG_LEVEL` | `info` | debug / info / warn / error | +| `WINDSURFAPI_IGNORE_CLOUD_FILTER` | `0` | Cascade 路径下,各账号云端 catalog 同步后,账号池列表展示活跃账号目录的并集,路由则校验所选账号自己的目录;设为 `1` 恢复完整静态 catalog。目录缺失、为空或同步失败时保持 fail-open;`DEVIN_CONNECT` 使用独立 selector catalog | | `LS_BINARY_PATH` | `/opt/windsurf/language_server_linux_x64` | LS 二进制位置 | | `LS_DATA_DIR` | Linux: `/opt/windsurf/data`;macOS: `~/.windsurf/data` | 每个 proxy 独立的 LS 数据根目录 | | `LS_PORT` | `42100` | LS gRPC 端口 | @@ -325,7 +326,7 @@ curl http://localhost:3003/v1/messages \ ## 支持的模型 -主线 100+ 个静态模型 + Windsurf 雲端動態下發(`mergeCloudModels` 啟動時拉取最新)。完整列表查 `GET /v1/models`,或看 [GitHub Pages 模型清单](https://dwgx.github.io/WindsurfAPI/#models)(同步生成於 `src/models.js`)。 +主线 100+ 个静态模型 + Windsurf 雲端動態下發(`mergeCloudModels` 啟動時拉取最新)。Cascade 路径下,各账号云端 catalog 同步后,`GET /v1/models` 和 Dashboard 展示活跃账号目录的并集,路由则校验所选账号自己的目录;`DEVIN_CONNECT` 继续使用独立 selector catalog;静态完整列表仍可查看 [GitHub Pages 模型清单](https://dwgx.github.io/WindsurfAPI/#models)(同步生成於 `src/models.js`)。
Claude(Anthropic) — 21 个 diff --git a/src/auth.js b/src/auth.js index 8481e0cf..c28081ef 100644 --- a/src/auth.js +++ b/src/auth.js @@ -18,7 +18,18 @@ import { config, log } from './config.js'; import { safeAccountRef } from './log-safety.js'; import { renameSyncWithRetry, writeFileSyncDurable } from './fs-atomic.js'; import { getEffectiveProxy } from './dashboard/proxy-config.js'; -import { getTierModels, getModelKeysByEnum, MODELS, registerDiscoveredFreeModel } from './models.js'; +import { + getTierModels, + getModelKeysByEnum, + MODELS, + registerDiscoveredFreeModel, + filterModelKeysByCloudCatalog, + isModelAllowedByCloudCatalog, + clearCloudModelCatalogs, + mergeCloudCatalogSnapshot, + removeCloudModelCatalog, + setActiveCloudCatalogAccounts, +} from './models.js'; import { FREE_REACHABLE_SELECTORS } from './devin-connect-models.js'; import { getLsAdmissionStatus, getLsMaintenanceRequests } from './langserver.js'; import { bumpConnect } from './devin-connect-metrics.js'; @@ -188,6 +199,10 @@ export function isModelBlockedByDrought(modelKey) { if (!isDroughtRestrictEnabled()) return false; if (!isDroughtMode()) return false; const freeModels = new Set(getTierModels('free')); + // A restricted catalog may contain no free-tier models. In that case the + // drought restriction cannot select a usable fallback, so leave the + // account catalog as the authoritative policy. + if (freeModels.size === 0) return false; return !freeModels.has(modelKey); } @@ -207,15 +222,19 @@ export function getDroughtSummary() { lowestDaily = lowestDaily == null ? c.dailyPercent : Math.min(lowestDaily, c.dailyPercent); } } + const drought = isDroughtMode(); + const restrictEnabled = isDroughtRestrictEnabled(); + const freeTierModels = getTierModels('free'); return { - drought: isDroughtMode(), + drought, threshold: getDroughtThresholdPercent(), activeAccounts: eligible.length, knownAccounts, lowestWeeklyPercent: lowestWeekly, lowestDailyPercent: lowestDaily, - restrictEnabled: isDroughtRestrictEnabled(), - freeTierModels: getTierModels('free'), + restrictEnabled, + freeTierModels, + restrictionFailOpen: drought && restrictEnabled && freeTierModels.length === 0, }; } @@ -644,65 +663,215 @@ export function __maybeRecoverErrorAccount(account, now = Date.now()) { // ─── Dynamic model catalog from cloud ───────────────────── -// Tracks whether the cloud model catalog has been successfully merged at -// least once since startup. When false, trySyncModelCatalog() will fire -// off a fetch whenever an account becomes active — covering the case where -// the startup fetch was skipped because no active account existed yet. -let _modelCatalogSynced = false; -// Coalesces concurrent calls so multiple simultaneous status transitions -// (e.g. bulk-add accounts) only trigger one fetch. -let _modelCatalogSyncPromise = null; - -async function fetchAndMergeModelCatalog() { - // Use the first active account to fetch the catalog. - const acct = accounts.find(a => a.status === 'active' && a.apiKey); - if (!acct) { - log.debug('No active account for model catalog fetch'); - return false; +// Per-account synchronization state. Catalog responses are account-scoped, so +// mixed pools must not reuse the first active account's policy for every route. +const _modelCatalogSyncedKeys = new Map(); // account id → apiKey used for sync +const _modelCatalogSyncPromises = new Map(); // account id → { apiKey, promise } +const _modelCatalogRetryCancels = new Map(); // account id → cancel delayed confirmation +const _modelCatalogConfirmationAttemptKeys = new Map(); // account id → apiKey being confirmed +const MODEL_CATALOG_CONFIRM_RETRY_MS = 30_000; +let _connectCatalogSynced = false; +let _connectCatalogSyncPromise = null; +let _modelCatalogDeps = null; + +/** Test seams for deterministic catalog synchronization without network calls. */ +export function __setModelCatalogDeps(deps) { + _modelCatalogDeps = deps; +} + +export async function __waitForModelCatalogSync() { + await Promise.allSettled( + [..._modelCatalogSyncPromises.values()].map(({ promise }) => promise), + ); + if (_connectCatalogSyncPromise) { + await Promise.allSettled([_connectCatalogSyncPromise]); + } +} + +function cancelModelCatalogRetry(accountId) { + const cancel = _modelCatalogRetryCancels.get(accountId); + _modelCatalogRetryCancels.delete(accountId); + if (cancel) cancel(); +} + +// This is one delayed confirmation, not periodic catalog refresh. Ordinary +// sync triggers are coalesced while the timer is pending, and a mismatched +// confirmation does not self-schedule another polling cycle. +function scheduleModelCatalogRetry(accountId, apiKey) { + if (_modelCatalogRetryCancels.has(accountId)) return; + const retry = () => { + _modelCatalogRetryCancels.delete(accountId); + const account = accounts.find(a => ( + a.id === accountId + && a.status === 'active' + && a.apiKey === apiKey + )); + if (!account) return; + _modelCatalogConfirmationAttemptKeys.set(accountId, apiKey); + trySyncModelCatalog(); + if (!_modelCatalogSyncPromises.has(accountId)) { + _modelCatalogConfirmationAttemptKeys.delete(accountId); + } + }; + + if (_modelCatalogDeps?.scheduleCatalogRetry) { + const cancel = _modelCatalogDeps.scheduleCatalogRetry( + retry, + MODEL_CATALOG_CONFIRM_RETRY_MS, + ); + _modelCatalogRetryCancels.set(accountId, typeof cancel === 'function' ? cancel : () => {}); + return; } - try { - const { getCascadeModelConfigs } = await import('./windsurf-api.js'); - const { mergeCloudModels } = await import('./models.js'); - const proxy = getEffectiveProxy(acct.id) || null; - const { configs } = await getCascadeModelConfigs(acct.apiKey, proxy); - const added = mergeCloudModels(configs); - _modelCatalogSynced = true; - log.info(`Model catalog: ${configs.length} cloud models, ${added} new entries merged`); - // Also refresh the DEVIN_CONNECT selector catalog (audit 2026-07-12): the - // committed snapshot never live-synced, so upstream-added selectors - // (qwen-3/glm-5/kimi/deepseek/minimax) were 400'd by the strict gate despite - // being runnable. GetCliModelConfigs is the connect-namespace source (distinct - // from Cascade's GetCascadeModelConfigs above). Best-effort + isolated: a - // connect-catalog failure must not fail the Cascade merge (already succeeded). + + const timer = setTimeout(retry, MODEL_CATALOG_CONFIRM_RETRY_MS); + timer.unref?.(); + _modelCatalogRetryCancels.set(accountId, () => clearTimeout(timer)); +} + +export function __resetModelCatalogState() { + for (const accountId of [..._modelCatalogRetryCancels.keys()]) { + cancelModelCatalogRetry(accountId); + } + _modelCatalogSyncedKeys.clear(); + _modelCatalogSyncPromises.clear(); + _modelCatalogConfirmationAttemptKeys.clear(); + _connectCatalogSynced = false; + _connectCatalogSyncPromise = null; + clearCloudModelCatalogs(); +} + +function activeModelCatalogAccounts() { + return accounts.filter(a => a.status === 'active' && a.apiKey); +} + +function reconcileModelCatalogAccounts() { + const active = activeModelCatalogAccounts(); + const activeIds = new Set(active.map(a => a.id)); + setActiveCloudCatalogAccounts(activeIds); + for (const accountId of _modelCatalogSyncedKeys.keys()) { + if (activeIds.has(accountId)) continue; + _modelCatalogSyncedKeys.delete(accountId); + removeCloudModelCatalog(accountId); + } + for (const accountId of [..._modelCatalogRetryCancels.keys()]) { + if (activeIds.has(accountId)) continue; + cancelModelCatalogRetry(accountId); + removeCloudModelCatalog(accountId); + } + return active; +} + +function invalidateModelCatalogForAccount(accountId) { + _modelCatalogSyncedKeys.delete(accountId); + _modelCatalogConfirmationAttemptKeys.delete(accountId); + cancelModelCatalogRetry(accountId); + removeCloudModelCatalog(accountId); +} + +function trySyncConnectCatalog(acct) { + if (_modelCatalogDeps?.disableConnectSync) return; + if (_connectCatalogSynced || _connectCatalogSyncPromise) return; + _connectCatalogSyncPromise = (async () => { try { - const { fetchCatalog } = await import('./devin-connect-catalog.js'); - const { setLiveCatalogSelectors } = await import('./devin-connect-models.js'); + const { fetchCatalog } = _modelCatalogDeps?.fetchConnectCatalog + ? { fetchCatalog: _modelCatalogDeps.fetchConnectCatalog } + : await import('./devin-connect-catalog.js'); + const { setLiveCatalogSelectors } = _modelCatalogDeps?.setLiveCatalogSelectors + ? { setLiveCatalogSelectors: _modelCatalogDeps.setLiveCatalogSelectors } + : await import('./devin-connect-models.js'); const connectModels = await fetchCatalog({ token: acct.apiKey }); setLiveCatalogSelectors(connectModels); + _connectCatalogSynced = true; log.info(`DEVIN_CONNECT live catalog: ${connectModels.length} selectors merged into resolver`); - } catch (ce) { - log.warn(`DEVIN_CONNECT catalog sync failed (snapshot fallback stays in effect): ${ce.message}`); + } catch (e) { + log.warn(`DEVIN_CONNECT catalog sync failed (snapshot fallback stays in effect): ${e.message}`); + } + })().finally(() => { + _connectCatalogSyncPromise = null; + }); +} + +async function fetchAndMergeModelCatalog(accountId, apiKey) { + const confirmationAttempt = _modelCatalogConfirmationAttemptKeys.get(accountId) === apiKey; + _modelCatalogConfirmationAttemptKeys.delete(accountId); + const acct = accounts.find(a => ( + a.id === accountId + && a.status === 'active' + && a.apiKey === apiKey + )); + if (!acct) return false; + // The Connect selector catalog has its own RPC and namespace. Start that + // synchronization independently so a quarantined or malformed Cascade + // snapshot cannot delay Connect model discovery. + trySyncConnectCatalog(acct); + try { + const { getCascadeModelConfigs } = _modelCatalogDeps?.getCascadeModelConfigs + ? { getCascadeModelConfigs: _modelCatalogDeps.getCascadeModelConfigs } + : await import('./windsurf-api.js'); + const { listModels } = await import('./models.js'); + const proxy = getEffectiveProxy(acct.id) || null; + const result = await getCascadeModelConfigs(acct.apiKey, proxy); + const current = accounts.find(a => ( + a.id === accountId + && a.status === 'active' + && a.apiKey === apiKey + )); + if (!current) return false; + + const snapshot = mergeCloudCatalogSnapshot(result?.configs, { accountId }); + if (!snapshot.accepted) { + if (snapshot.reason === 'malformed') { + cancelModelCatalogRetry(accountId); + log.warn(`Model catalog returned malformed configs for ${safeAccountRef(acct)}`); + return false; + } + if (!confirmationAttempt) scheduleModelCatalogRetry(accountId, apiKey); + const baseline = snapshot.baselineSource === 'last_accepted' + ? `the last accepted ${snapshot.baselineCount}` + : `the static baseline of ${snapshot.baselineCount}`; + const nextStep = confirmationAttempt + ? 'the delayed confirmation differed, so the candidate remains quarantined' + : `retrying once in ${MODEL_CATALOG_CONFIRM_RETRY_MS / 1000}s`; + log.warn(`Model catalog for ${safeAccountRef(acct)} returned ${snapshot.receivedCount} unique models versus ${baseline}; preserving the current fail-open/last-known-good view and ${nextStep}`); + return false; } + + cancelModelCatalogRetry(accountId); + const configs = result.configs; + const visible = listModels().length; + _modelCatalogSyncedKeys.set(accountId, apiKey); + log.info(`Model catalog for ${safeAccountRef(acct)}: ${configs.length} cloud models, ${snapshot.added} new entries merged, ${visible} pool models visible`); return true; } catch (e) { - log.warn(`Model catalog fetch failed: ${e.message}`); + log.warn(`Model catalog fetch failed for ${safeAccountRef(acct)}: ${e.message}`); return false; } } /** - * Fire-and-forget: trigger a cloud model catalog sync if one hasn't succeeded - * yet and at least one active account exists. Safe to call from any account - * status transition path — coalesces concurrent calls into a single fetch. + * Fire-and-forget: synchronize every active account whose current API key has + * not produced a catalog yet. Per-account promises coalesce concurrent status + * transitions without blocking unrelated accounts. */ export function trySyncModelCatalog() { - if (_modelCatalogSynced) return; - if (_modelCatalogSyncPromise) return; - const acct = accounts.find(a => a.status === 'active' && a.apiKey); - if (!acct) return; - _modelCatalogSyncPromise = fetchAndMergeModelCatalog() - .catch(e => log.warn(`trySyncModelCatalog: ${e.message}`)) - .finally(() => { _modelCatalogSyncPromise = null; }); + const active = reconcileModelCatalogAccounts(); + for (const acct of active) { + if (_modelCatalogSyncedKeys.get(acct.id) === acct.apiKey) continue; + if (_modelCatalogRetryCancels.has(acct.id)) continue; + if (_modelCatalogSyncedKeys.has(acct.id)) { + invalidateModelCatalogForAccount(acct.id); + } + const inFlight = _modelCatalogSyncPromises.get(acct.id); + if (inFlight?.apiKey === acct.apiKey) continue; + const apiKey = acct.apiKey; + const promise = fetchAndMergeModelCatalog(acct.id, apiKey) + .catch(e => log.warn(`trySyncModelCatalog: ${e.message}`)) + .finally(() => { + const current = _modelCatalogSyncPromises.get(acct.id); + if (current?.promise === promise) _modelCatalogSyncPromises.delete(acct.id); + }); + _modelCatalogSyncPromises.set(acct.id, { apiKey, promise }); + } } async function registerWithCodeium(idToken) { @@ -812,7 +981,9 @@ export async function addAccountByEmail(email, password) { const existingByEmail = accounts.find(a => String(a.email || '').trim().toLowerCase() === emailKey); const account = existingByEmail || addAccountByKey(result.apiKey, label); if (existingByEmail) { + const apiKeyChanged = account.apiKey !== result.apiKey; account.apiKey = result.apiKey; + if (apiKeyChanged) invalidateModelCatalogForAccount(account.id); if (account.status === 'error') account.status = 'active'; account.errorCount = 0; } @@ -875,6 +1046,10 @@ export function setAccountBlockedModels(id, blockedModels) { * at selector time even though `account.capabilities` already says yes. */ export function isModelAllowedForAccount(account, modelKey) { + // The candidate account's upstream catalog is the outermost gate. + // Entitlements and manual tier overrides may narrow or widen within that + // catalog, but must never re-enable a model omitted for this account. + if (!isModelAllowedByCloudCatalog(modelKey, process.env, account.id)) return false; const blocked = account.blockedModels || []; if (blocked.includes(modelKey)) return false; // tierManual is the operator escape hatch: when set, trust the manual @@ -890,7 +1065,7 @@ export function isModelAllowedForAccount(account, modelKey) { return cap.ok === true; } } - const tierModels = getTierModels(account.tier || 'unknown'); + const tierModels = getTierModels(account.tier || 'unknown', account.id); return tierModels.includes(modelKey); } @@ -923,7 +1098,7 @@ export function hasConnectEntitledAccount(selector) { /** List of model keys this account is currently allowed to call. */ export function getAvailableModelsForAccount(account) { const blocked = new Set(account.blockedModels || []); - const tierModels = getTierModels(account.tier || 'unknown'); + const tierModels = getTierModels(account.tier || 'unknown', account.id); // Manual tier override or no GetUserStatus yet → tier static table. if (account.tierManual || !account.userStatusLastFetched || !account.capabilities) { return tierModels.filter(m => !blocked.has(m)); @@ -933,6 +1108,7 @@ export function getAvailableModelsForAccount(account) { const allowed = []; for (const [key, info] of Object.entries(MODELS)) { if (blocked.has(key)) continue; + if (!isModelAllowedByCloudCatalog(key, process.env, account.id)) continue; if (info.enumValue && info.enumValue > 0) { const cap = account.capabilities[key]; if (cap?.reason === 'user_status' && cap.ok === true) allowed.push(key); @@ -953,7 +1129,7 @@ export function setAccountStatus(id, status) { if (status === 'active') account.errorCount = 0; saveAccounts(); log.info(`Account ${id} status set to ${status}`); - if (status === 'active') trySyncModelCatalog(); + trySyncModelCatalog(); return true; } @@ -1008,9 +1184,14 @@ export function setAccountTier(id, tier) { export function setAccountTokens(id, { apiKey, refreshToken, idToken } = {}) { const account = accounts.find(a => a.id === id); if (!account) return false; + const apiKeyChanged = apiKey != null && account.apiKey !== apiKey; if (apiKey != null) account.apiKey = apiKey; if (refreshToken != null) account.refreshToken = refreshToken; if (idToken != null) account.idToken = idToken; + if (apiKeyChanged) { + invalidateModelCatalogForAccount(account.id); + trySyncModelCatalog(); + } saveAccounts(); return true; } @@ -1126,13 +1307,16 @@ export async function reLoginAccount(id, { force = false } = {}) { const { windsurfLogin } = _reloginDeps || await import('./dashboard/windsurf-login.js'); const result = await windsurfLogin(account.email, password, proxy); if (!result?.apiKey) throw new Error('login returned no apiKey'); + const apiKeyChanged = account.apiKey !== result.apiKey; account.apiKey = result.apiKey; + if (apiKeyChanged) invalidateModelCatalogForAccount(account.id); if (result.refreshToken) account.refreshToken = result.refreshToken; account.status = 'active'; account.errorCount = 0; account._errorAt = 0; account._reloginAt = Date.now(); saveAccounts(); + trySyncModelCatalog(); log.info(`re-login OK: ${safeAccountRef(account)} → fresh session token`); bumpConnect('relogin_ok'); return result.apiKey; @@ -1184,6 +1368,7 @@ export async function probeAndRecoverConnectAccount(id, { signal } = {}) { account.errorCount = 0; account._errorAt = 0; saveAccounts(); + trySyncModelCatalog(); log.info(`liveness probe: ${safeAccountRef(account)} recovered to active`); } return { alive: true }; @@ -1209,6 +1394,8 @@ export function removeAccount(id) { if (idx === -1) return false; const account = accounts[idx]; accounts.splice(idx, 1); + invalidateModelCatalogForAccount(id); + trySyncModelCatalog(); saveAccounts(); // Drop any Cascade conversations owned by this key so future requests // don't try to resume on an account that no longer exists. @@ -2193,6 +2380,7 @@ export function reportError(apiKey, { windowMs = getBreakerTunable('errorWindowM // known-bad key (reportBanSignal already saves on its flip; this mirrors // it). Only saves when the status actually changes, not on every error. saveAccounts(); + trySyncModelCatalog(); log.warn(`Account ${safeAccountRef(account)} disabled after ${account.errorCount} errors in ${Math.round(windowMs / 60000)}m`); } } @@ -2353,6 +2541,7 @@ export function reportBanSignal(apiKey, message, { windowMs = 30 * 60 * 1000 } = account.bannedAt = now; account.bannedReason = account._banSignalLastMessage; saveAccounts(); + trySyncModelCatalog(); log.error(`Account ${safeAccountRef(account)} marked BANNED after ${account._banSignalCount} ban-shaped errors`); // Drop any cascade-pool entries owned by this key. import('./conversation-pool.js').then(m => m.invalidateFor({ apiKey })).catch(() => {}); @@ -2487,7 +2676,7 @@ function accountUserStatusSummary(userStatus) { function publicAccount(a, now, { view = 'full' } = {}) { const rpmLimit = rpmLimitFor(a); const rpmUsed = pruneRpmHistory(a, now); - const tierModels = getTierModels(a.tier || 'unknown'); + const tierModels = getTierModels(a.tier || 'unknown', a.id); const cr = a.credits || null; const base = { id: a.id, @@ -3024,7 +3213,11 @@ async function _probeAccountImpl(account, { allowLsStart = true, canary } = {}) // and haven't been classified yet. This discovers models available to free // accounts beyond the hardcoded FREE_TIER_MODELS list. if (runCanary) try { - const allModels = Object.keys(MODELS); + const allModels = filterModelKeysByCloudCatalog( + Object.keys(MODELS), + process.env, + account.id, + ); const alreadyProbed = new Set([ ...PROBE_CANARIES, ...Object.keys(account.capabilities || {}), @@ -3388,6 +3581,8 @@ async function refreshAllFirebaseTokens({ skipBusy = false } = {}) { if (apiKey && apiKey !== a.apiKey) { log.info(`Firebase refresh: ${safeAccountRef(a)} got new API key`); a.apiKey = apiKey; + invalidateModelCatalogForAccount(a.id); + trySyncModelCatalog(); } a._refreshFailStreak = 0; saveAccounts(); @@ -3403,6 +3598,7 @@ async function refreshAllFirebaseTokens({ skipBusy = false } = {}) { a.status = 'error'; a.erroredAt = Date.now(); saveAccounts(); + trySyncModelCatalog(); log.warn(`Account ${safeAccountRef(a)} downgraded to error after ${a._refreshFailStreak} consecutive Firebase refresh failures`); } } @@ -3473,7 +3669,7 @@ export async function initAuth() { // Fetch live model catalog from cloud and merge into hardcoded catalog. // Fire-and-forget — the hardcoded catalog is sufficient until this completes. - // trySyncModelCatalog also fires again when the first account becomes active. + // trySyncModelCatalog also fires whenever an account becomes active. trySyncModelCatalog(); // Periodic Firebase token refresh (every 50 min). Firebase ID tokens expire diff --git a/src/dashboard/api.js b/src/dashboard/api.js index 21b6f4fa..f742ec36 100644 --- a/src/dashboard/api.js +++ b/src/dashboard/api.js @@ -36,7 +36,7 @@ import { getClineCompatStats } from '../handlers/cline-compat.js'; import { getCcCompatStats } from '../handlers/cc-compat.js'; 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 } from '../models.js'; +import { MODELS, MODEL_TIER_ACCESS as _TIER_TABLE, getTierModels as _getTierModels, filterModelKeysByCloudCatalog } from '../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'; @@ -1185,7 +1185,7 @@ export async function handleDashboardApi(method, subpath, body, req, res) { pro: _TIER_TABLE.pro, unknown: _TIER_TABLE.unknown, expired: _TIER_TABLE.expired, - allModels: Object.keys(MODELS), + allModels: filterModelKeysByCloudCatalog(), }); } @@ -1759,10 +1759,15 @@ export async function handleDashboardApi(method, subpath, body, req, res) { // ─── Models list ────────────────────────────────────── if (subpath === '/models' && method === 'GET') { - const models = Object.entries(MODELS).map(([id, info]) => ({ - id, name: info.name, provider: info.provider, - credit: typeof info.credit === 'number' ? info.credit : null, - })); + const models = filterModelKeysByCloudCatalog().map((id) => { + const info = MODELS[id]; + return { + id, + name: info.name, + provider: info.provider, + credit: typeof info.credit === 'number' ? info.credit : null, + }; + }); return json(res, 200, { models }); } diff --git a/src/dashboard/i18n/en.json b/src/dashboard/i18n/en.json index 0031d543..efb77a3d 100644 --- a/src/dashboard/i18n/en.json +++ b/src/dashboard/i18n/en.json @@ -784,6 +784,7 @@ "drought": { "title": "Quota drought:", "body": "Every active account is below the weekly quota threshold — add accounts or wait for reset", + "restrictionFailOpen": "No free model is available for the active accounts, so low-quota protection will not block paid models; models unsupported by an account are still hidden and blocked", "detail": "min weekly={{weekly}}% · min daily={{daily}}% · {{known}}/{{active}} accounts known" }, "tooltip": { diff --git a/src/dashboard/i18n/zh-CN.json b/src/dashboard/i18n/zh-CN.json index 66b3386c..ab599a88 100644 --- a/src/dashboard/i18n/zh-CN.json +++ b/src/dashboard/i18n/zh-CN.json @@ -784,6 +784,7 @@ "drought": { "title": "配额预警:", "body": "所有账号本周配额都低于阈值,建议补账号或等待重置", + "restrictionFailOpen": "当前可用账号中没有免费模型,因此低配额保护不会阻止付费模型;账号本身不支持的模型仍会被隐藏和拦截", "detail": "最低周额度={{weekly}}% · 最低日额度={{daily}}% · 已知 {{known}}/{{active}} 个账号" }, "tooltip": { diff --git a/src/dashboard/index-sketch.html b/src/dashboard/index-sketch.html index b5d49254..8ffaff33 100644 --- a/src/dashboard/index-sketch.html +++ b/src/dashboard/index-sketch.html @@ -1746,6 +1746,7 @@

Account Manager

@@ -2696,12 +2697,18 @@

Credits

async loadDrought() { const banner = document.getElementById('drought-banner'); + const msg = document.getElementById('drought-message'); + const failOpenMsg = document.getElementById('drought-fail-open-message'); const detail = document.getElementById('drought-detail'); if (!banner) return; try { const d = await this.api('GET', '/drought'); if (!d?.drought) { banner.style.display = 'none'; return; } banner.style.display = ''; + if (msg && failOpenMsg) { + msg.style.display = d.restrictionFailOpen ? 'none' : ''; + failOpenMsg.style.display = d.restrictionFailOpen ? '' : 'none'; + } if (detail) { const lw = d.lowestWeeklyPercent; const ld = d.lowestDailyPercent; diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 62634145..345a22cf 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -9127,7 +9127,9 @@

控制台登录

known: d.knownAccounts, active: d.activeAccounts, }); - msg.textContent = I18n.t('drought.body') || msg.textContent; + msg.textContent = d.restrictionFailOpen + ? I18n.t('drought.restrictionFailOpen') + : (I18n.t('drought.body') || msg.textContent); detail.textContent = summary; } catch (e) { // no /drought endpoint on older backends — silently hide. diff --git a/src/handlers/models.js b/src/handlers/models.js index 0da61443..96a00132 100644 --- a/src/handlers/models.js +++ b/src/handlers/models.js @@ -8,8 +8,12 @@ import { getBackendSwitch } from '../runtime-config.js'; // The MODELS table stays full for the Cascade transport; this is a per-transport // view, not a catalog edit. Non-connect deployments see the full list unchanged. export function handleModels(env = process.env) { - let data = listModels(); - if (getBackendSwitch('devinConnect', env)) { + const effectiveEnv = env === process.env ? env : { ...process.env, ...env }; + // listModels receives the same effective environment used for transport + // selection so a DEVIN_CONNECT request is never pre-filtered by the + // unrelated Cascade cloud catalog. + let data = listModels({ env: effectiveEnv }); + if (getBackendSwitch('devinConnect', effectiveEnv)) { // Existence = snapshot ∪ live (same source of truth as resolveConnectSelector, // audit 2026-07-12). Before this, the filter only consulted the frozen // CATALOG_SELECTORS snapshot, so live-synced selectors were dropped here even diff --git a/src/models.js b/src/models.js index 3bbe4ef9..710cb5f9 100644 --- a/src/models.js +++ b/src/models.js @@ -599,9 +599,141 @@ export function registerDiscoveredFreeModel(key) { if (MODELS[key] && !FREE_TIER_BASE.includes(key)) _discoveredFreeModels.add(key); } +// ─── Cloud catalog filter ───────────────────────────────── +// GetCascadeModelConfigs is fetched with one upstream account. Keep each +// account's response separate: the pool-wide catalog is their union, while +// routing checks the catalog for the candidate account only. Special-agent +// backends keep their own catalog and are not governed by this response. +const DEFAULT_CLOUD_CATALOG_ACCOUNT = Symbol('default-cloud-catalog-account'); +const _cloudCatalogUidsByAccount = new Map(); +const _activeCloudCatalogAccounts = new Set(); +const _pendingCloudCatalogUidsByAccount = new Map(); +const CLOUD_CATALOG_CONFIRM_RATIO = 0.5; + +function normalizeCloudCatalogUid(uid) { + return typeof uid === 'string' ? uid.trim().toLowerCase() : ''; +} + +const STATIC_CLOUD_CATALOG_UID_COUNT = new Set( + Object.values(MODELS) + .filter(model => !model?.deprecated && model?.backend !== 'special_agent') + .map(model => normalizeCloudCatalogUid(model?.modelUid)) + .filter(Boolean), +).size; + +function cloudCatalogAccountKey(accountId) { + if (accountId === undefined || accountId === null || accountId === '') { + return DEFAULT_CLOUD_CATALOG_ACCOUNT; + } + return String(accountId); +} + +/** + * Set the accounts that currently contribute to pool-wide model listings. + * + * A listing fails open until every active account has a usable catalog. This + * prevents a newly-added or temporarily-unsynced account from having models + * hidden before its own upstream response arrives. + */ +export function setActiveCloudCatalogAccounts(accountIds) { + const nextActiveAccounts = new Set(); + for (const accountId of accountIds || []) { + if (accountId === undefined || accountId === null || accountId === '') continue; + nextActiveAccounts.add(String(accountId)); + } + for (const accountId of _activeCloudCatalogAccounts) { + if (nextActiveAccounts.has(accountId)) continue; + _cloudCatalogUidsByAccount.delete(accountId); + _pendingCloudCatalogUidsByAccount.delete(accountId); + } + _activeCloudCatalogAccounts.clear(); + for (const accountId of nextActiveAccounts) _activeCloudCatalogAccounts.add(accountId); +} + +/** Remove one account's catalog when it leaves the active pool or its key changes. */ +export function removeCloudModelCatalog(accountId) { + const accountKey = cloudCatalogAccountKey(accountId); + _cloudCatalogUidsByAccount.delete(accountKey); + _pendingCloudCatalogUidsByAccount.delete(accountKey); +} + +/** Clear all in-memory catalog state. Primarily useful for deterministic tests. */ +export function clearCloudModelCatalogs() { + _cloudCatalogUidsByAccount.clear(); + _pendingCloudCatalogUidsByAccount.clear(); + _activeCloudCatalogAccounts.clear(); +} + +function applicableCloudCatalogUids(accountId) { + if (accountId !== undefined && accountId !== null && accountId !== '') { + const catalog = _cloudCatalogUidsByAccount.get(cloudCatalogAccountKey(accountId)); + return catalog?.size ? catalog : null; + } + + if (_activeCloudCatalogAccounts.size > 0) { + const union = new Set(); + for (const activeAccountId of _activeCloudCatalogAccounts) { + const catalog = _cloudCatalogUidsByAccount.get(activeAccountId); + if (!catalog?.size) return null; + for (const uid of catalog) union.add(uid); + } + return union; + } + + const fallback = _cloudCatalogUidsByAccount.get(DEFAULT_CLOUD_CATALOG_ACCOUNT); + return fallback?.size ? fallback : null; +} + +function isModelAllowedByCatalogUids(key, catalogUids) { + if (!catalogUids) return true; + const model = MODELS[key]; + if (!model) return false; + if (model.backend === 'special_agent') return true; + const uid = normalizeCloudCatalogUid(model.modelUid); + return uid !== '' && catalogUids.has(uid); +} + +/** Whether one model key is permitted by the relevant upstream account catalog. */ +export function isModelAllowedByCloudCatalog(key, env = process.env, accountId = null) { + if (env.WINDSURFAPI_IGNORE_CLOUD_FILTER === '1') return true; + // DEVIN_CONNECT uses a separate selector namespace and catalog source. + // Applying GetCascadeModelConfigs here can turn a valid Connect-only + // allowlist (for example swe-1-6-slow) into an empty model view because the + // selector has no equivalent entry in the Cascade MODELS table. + if (getBackendSwitch('devinConnect', env)) return true; + const catalogUids = applicableCloudCatalogUids(accountId); + return isModelAllowedByCatalogUids(key, catalogUids); +} + +/** + * Return model keys allowed by an account catalog or by the active pool union. + * + * Passing accountId applies only that account's response. Omitting it applies + * the union used by pool-wide model listings. Before every relevant catalog is + * usable (or when explicitly disabled), this fails open. + */ +export function filterModelKeysByCloudCatalog( + keys = Object.keys(MODELS), + env = process.env, + accountId = null, +) { + const input = Array.from(keys || []); + if (env.WINDSURFAPI_IGNORE_CLOUD_FILTER === '1') return input; + if (getBackendSwitch('devinConnect', env)) return input; + const catalogUids = applicableCloudCatalogUids(accountId); + if (!catalogUids) return input; + return input.filter((key) => isModelAllowedByCatalogUids(key, catalogUids)); +} + +function baseTierModels(tier) { + if (tier === 'free') return [...FREE_TIER_BASE, ..._discoveredFreeModels]; + if (tier === 'expired') return []; + return Object.keys(MODELS); +} + export const MODEL_TIER_ACCESS = { - get pro() { return Object.keys(MODELS); }, - get free() { return [...FREE_TIER_BASE, ..._discoveredFreeModels]; }, + get pro() { return filterModelKeysByCloudCatalog(baseTierModels('pro')); }, + get free() { return filterModelKeysByCloudCatalog(baseTierModels('free')); }, // Optimistic: a freshly-added account whose probe hasn't completed yet // gets the FULL pro catalog, not just gemini-2.5-flash. Otherwise the // chat.js anyEligible check (line ~1141) immediately 403s any non-free @@ -611,13 +743,14 @@ export const MODEL_TIER_ACCESS = { // upstream with a real entitlement error from the LS, which is a more // accurate failure than the misleading "model not in account pool" we // were emitting. Reported in QQ group, 2026-04-30. - get unknown() { return Object.keys(MODELS); }, + get unknown() { return filterModelKeysByCloudCatalog(baseTierModels('unknown')); }, expired: [], }; /** Models a given tier is entitled to. */ -export function getTierModels(tier) { - return MODEL_TIER_ACCESS[tier] || MODEL_TIER_ACCESS.unknown; +export function getTierModels(tier, accountId = null, env = process.env) { + const resolvedTier = ['pro', 'free', 'unknown', 'expired'].includes(tier) ? tier : 'unknown'; + return filterModelKeysByCloudCatalog(baseTierModels(resolvedTier), env, accountId); } function isSpecialAgentCatalogEnabled() { @@ -633,10 +766,12 @@ function isSpecialAgentCatalogEnabled() { /** List all models in OpenAI /v1/models format. Hides deprecated models. */ export function listModels(opts = {}) { const ts = Math.floor(Date.now() / 1000); + const env = opts.env ?? process.env; const specialAgentEnabled = opts.specialAgentEnabled ?? isSpecialAgentCatalogEnabled(); const includeDisabledSpecialAgent = opts.includeDisabledSpecialAgent ?? process.env.WINDSURFAPI_SHOW_DISABLED_SPECIAL_AGENT_MODELS === '1'; - return Object.entries(MODELS) + return filterModelKeysByCloudCatalog(Object.keys(MODELS), env) + .map((id) => [id, MODELS[id]]) .filter(([, info]) => !info.deprecated) .filter(([, info]) => info.backend !== 'special_agent' || specialAgentEnabled || includeDisabledSpecialAgent) .map(([id, info]) => ({ @@ -653,13 +788,24 @@ export function listModels(opts = {}) { })); } -/** - * Merge live model configs from GetCascadeModelConfigs into the catalog. - * Called once at startup after the first successful cloud fetch. - * Only adds NEW models not already in the catalog (doesn't overwrite enums). - */ -export function mergeCloudModels(configs) { - if (!Array.isArray(configs)) return 0; +function applyCloudModels(configs, { accountId = null } = {}) { + const catalogAccountKey = cloudCatalogAccountKey(accountId); + const safeConfigs = Array.isArray(configs) ? configs : []; + _pendingCloudCatalogUidsByAccount.delete(catalogAccountKey); + + // Replace this account's policy snapshot atomically. An empty/invalid + // response removes it so filtering remains fail-open for that account. + const nextCloudCatalogUids = new Set(); + for (const model of safeConfigs) { + const uid = normalizeCloudCatalogUid(model?.modelUid); + if (uid) nextCloudCatalogUids.add(uid); + } + if (nextCloudCatalogUids.size > 0) { + _cloudCatalogUidsByAccount.set(catalogAccountKey, nextCloudCatalogUids); + } else { + _cloudCatalogUidsByAccount.delete(catalogAccountKey); + } + let added = 0; const providerMap = { MODEL_PROVIDER_ANTHROPIC: 'anthropic', @@ -671,8 +817,8 @@ export function mergeCloudModels(configs) { MODEL_PROVIDER_MOONSHOT: 'moonshot', }; - for (const m of configs) { - const uid = m.modelUid; + for (const m of safeConfigs) { + const uid = typeof m?.modelUid === 'string' ? m.modelUid.trim() : ''; if (!uid) continue; // Already in catalog? if (_lookup.has(uid) || _lookup.has(uid.toLowerCase())) continue; @@ -695,3 +841,98 @@ export function mergeCloudModels(configs) { } return added; } + +/** + * Merge live model configs from GetCascadeModelConfigs into the catalog. + * Account-scoped snapshots pass through the shrink-confirmation guard. The + * default legacy catalog remains a direct merge for compatibility. + * Only adds NEW models not already in the catalog (doesn't overwrite enums). + */ +export function mergeCloudModels(configs, { accountId = null } = {}) { + if (accountId === undefined || accountId === null || accountId === '') { + return applyCloudModels(configs, { accountId }); + } + return mergeCloudCatalogSnapshot(configs, { accountId }).added; +} + +function cloudCatalogUidSet(configs) { + const uids = new Set(); + for (const model of configs || []) { + const uid = normalizeCloudCatalogUid(model?.modelUid); + if (uid) uids.add(uid); + } + return uids; +} + +function cloudCatalogSetsEqual(left, right) { + if (!left || left.size !== right.size) return false; + for (const uid of left) { + if (!right.has(uid)) return false; + } + return true; +} + +/** + * Validate and merge one fetched account catalog snapshot. + * + * A non-empty snapshot that is less than half the account's last accepted + * snapshot, or the static catalog when no account snapshot exists yet, is + * quarantined until the same UID set is returned in a later sync round. + */ +export function mergeCloudCatalogSnapshot(configs, { accountId = null } = {}) { + const accountKey = cloudCatalogAccountKey(accountId); + if (!Array.isArray(configs)) { + _pendingCloudCatalogUidsByAccount.delete(accountKey); + applyCloudModels([], { accountId }); + return { + accepted: false, + added: 0, + reason: 'malformed', + receivedCount: 0, + baselineCount: 0, + }; + } + + const nextUids = cloudCatalogUidSet(configs); + if (nextUids.size === 0) { + _pendingCloudCatalogUidsByAccount.delete(accountKey); + return { + accepted: true, + added: applyCloudModels(configs, { accountId }), + reason: 'no_filter', + receivedCount: 0, + baselineCount: 0, + }; + } + + const currentUids = _cloudCatalogUidsByAccount.get(accountKey); + const baselineCount = currentUids?.size || STATIC_CLOUD_CATALOG_UID_COUNT; + const baselineSource = currentUids?.size ? 'last_accepted' : 'static'; + const confirmationThreshold = Math.ceil(baselineCount * CLOUD_CATALOG_CONFIRM_RATIO); + const needsConfirmation = nextUids.size < confirmationThreshold; + + if (needsConfirmation) { + const pendingUids = _pendingCloudCatalogUidsByAccount.get(accountKey); + if (!cloudCatalogSetsEqual(pendingUids, nextUids)) { + _pendingCloudCatalogUidsByAccount.set(accountKey, nextUids); + return { + accepted: false, + added: 0, + reason: 'confirmation_required', + receivedCount: nextUids.size, + baselineCount, + baselineSource, + }; + } + } + + _pendingCloudCatalogUidsByAccount.delete(accountKey); + return { + accepted: true, + added: applyCloudModels(configs, { accountId }), + reason: needsConfirmation ? 'confirmed_small' : 'accepted', + receivedCount: nextUids.size, + baselineCount, + baselineSource, + }; +} diff --git a/test/cloud-catalog-backend-boundary.test.js b/test/cloud-catalog-backend-boundary.test.js new file mode 100644 index 00000000..9ef00107 --- /dev/null +++ b/test/cloud-catalog-backend-boundary.test.js @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { config } from '../src/config.js'; +import { + __resetModelCatalogState, + __setModelCatalogDeps, + __waitForModelCatalogSync, + addAccountByKey, + configureBindHost, + getDroughtSummary, + removeAccount, +} from '../src/auth.js'; +import { handleDashboardApi } from '../src/dashboard/api.js'; +import { handleModels } from '../src/handlers/models.js'; +import { _resetRuntimeConfigForTests } from '../src/runtime-config.js'; +import { + MODELS, + mergeCloudModels, + setActiveCloudCatalogAccounts, +} from '../src/models.js'; + +const CASCADE_FREE_KEY = 'gemini-2.5-flash'; +const CASCADE_ONLY_KEY = 'claude-4-sonnet'; +const ACCOUNT_ID = 'catalog-backend-boundary'; +const ORIGINAL_ALLOW_NO_AUTH = process.env.DASHBOARD_ALLOW_NO_AUTH; +const ORIGINAL_DEVIN_CONNECT = process.env.DEVIN_CONNECT; +const ORIGINAL_DASHBOARD_PASSWORD = config.dashboardPassword; +const ORIGINAL_API_KEY = config.apiKey; +const createdAccountIds = []; + +function fakeRes() { + return { + statusCode: 0, + body: '', + writeHead(status) { this.statusCode = status; }, + end(chunk) { this.body += chunk ? String(chunk) : ''; }, + json() { return this.body ? JSON.parse(this.body) : null; }, + }; +} + +function localReq(path) { + return { + url: `/dashboard/api${path}`, + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + }; +} + +function syncCascadeOnlyCatalog() { + setActiveCloudCatalogAccounts([ACCOUNT_ID]); + const configs = [{ modelUid: MODELS[CASCADE_ONLY_KEY].modelUid }]; + // These direct calls represent separate production sync rounds: the first + // quarantines a small snapshot and the second confirms it after a delay. + mergeCloudModels(configs, { accountId: ACCOUNT_ID }); + mergeCloudModels(configs, { accountId: ACCOUNT_ID }); +} + +beforeEach(() => { + _resetRuntimeConfigForTests(); + __resetModelCatalogState(); + __setModelCatalogDeps(null); + delete process.env.DEVIN_CONNECT; + process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; + config.dashboardPassword = ''; + config.apiKey = ''; + configureBindHost('127.0.0.1'); +}); + +afterEach(async () => { + for (const accountId of createdAccountIds.splice(0)) removeAccount(accountId); + await __waitForModelCatalogSync(); + __resetModelCatalogState(); + __setModelCatalogDeps(null); + _resetRuntimeConfigForTests(); + if (ORIGINAL_DEVIN_CONNECT === undefined) delete process.env.DEVIN_CONNECT; + else process.env.DEVIN_CONNECT = ORIGINAL_DEVIN_CONNECT; + if (ORIGINAL_ALLOW_NO_AUTH === undefined) delete process.env.DASHBOARD_ALLOW_NO_AUTH; + else process.env.DASHBOARD_ALLOW_NO_AUTH = ORIGINAL_ALLOW_NO_AUTH; + config.dashboardPassword = ORIGINAL_DASHBOARD_PASSWORD; + config.apiKey = ORIGINAL_API_KEY; + configureBindHost('0.0.0.0'); +}); + +describe('Cascade cloud catalog backend boundary', () => { + it('does not narrow DEVIN_CONNECT model listings', () => { + const connectEnv = { DEVIN_CONNECT: '1' }; + const baseline = handleModels(connectEnv).data.map((model) => model.id); + + syncCascadeOnlyCatalog(); + + assert.deepEqual( + handleModels(connectEnv).data.map((model) => model.id), + baseline, + 'a Cascade-only allowlist must not empty or narrow the Connect model view', + ); + }); + + it('does not narrow Dashboard model views in DEVIN_CONNECT mode', async () => { + process.env.DEVIN_CONNECT = '1'; + + const baselineModelsRes = fakeRes(); + await handleDashboardApi('GET', '/models', {}, localReq('/models'), baselineModelsRes); + const baselineTierRes = fakeRes(); + await handleDashboardApi('GET', '/tier-access', {}, localReq('/tier-access'), baselineTierRes); + + syncCascadeOnlyCatalog(); + + const modelsRes = fakeRes(); + await handleDashboardApi('GET', '/models', {}, localReq('/models'), modelsRes); + assert.deepEqual( + modelsRes.json().models.map((model) => model.id), + baselineModelsRes.json().models.map((model) => model.id), + ); + + const tierRes = fakeRes(); + await handleDashboardApi('GET', '/tier-access', {}, localReq('/tier-access'), tierRes); + assert.deepEqual(tierRes.json().allModels, baselineTierRes.json().allModels); + }); + + it('does not delay the independent Connect catalog behind Cascade confirmation', async () => { + const runId = Date.now().toString(36); + const apiKey = `catalog-connect-independent-${runId}`; + let cascadeRequests = 0; + let connectRequests = 0; + let connectRows = null; + let scheduledRetry; + __setModelCatalogDeps({ + scheduleCatalogRetry: (retry) => { + scheduledRetry = retry; + return () => {}; + }, + getCascadeModelConfigs: async () => { + cascadeRequests += 1; + return { configs: [{ modelUid: MODELS[CASCADE_ONLY_KEY].modelUid }] }; + }, + fetchConnectCatalog: async ({ token }) => { + connectRequests += 1; + assert.equal(token, apiKey); + return [{ selector: 'swe-1-6-slow', provider: 'windsurf' }]; + }, + setLiveCatalogSelectors: (rows) => { + connectRows = rows; + }, + }); + + const account = addAccountByKey(apiKey, 'catalog-connect-independent'); + createdAccountIds.push(account.id); + await __waitForModelCatalogSync(); + + assert.equal(cascadeRequests, 1); + assert.equal(typeof scheduledRetry, 'function'); + assert.equal(connectRequests, 1); + assert.deepEqual(connectRows, [{ selector: 'swe-1-6-slow', provider: 'windsurf' }]); + }); + + it('does not expose the Cascade drought fail-open warning in DEVIN_CONNECT mode', async () => { + process.env.DEVIN_CONNECT = '1'; + const runId = Date.now().toString(36); + const apiKey = `catalog-connect-drought-${runId}`; + let scheduledRetry; + __setModelCatalogDeps({ + disableConnectSync: true, + scheduleCatalogRetry: (retry) => { + scheduledRetry = retry; + return () => {}; + }, + getCascadeModelConfigs: async () => ({ + configs: [{ modelUid: MODELS[CASCADE_ONLY_KEY].modelUid }], + }), + }); + + const account = addAccountByKey(apiKey, 'catalog-connect-drought'); + createdAccountIds.push(account.id); + account.credits = { weeklyPercent: 0, dailyPercent: 0 }; + await __waitForModelCatalogSync(); + scheduledRetry(); + await __waitForModelCatalogSync(); + + const summary = getDroughtSummary(); + assert.equal(summary.drought, true); + assert.equal(summary.restrictionFailOpen, false); + assert.ok(summary.freeTierModels.includes(CASCADE_FREE_KEY)); + }); +}); diff --git a/test/cloud-catalog-filter.test.js b/test/cloud-catalog-filter.test.js new file mode 100644 index 00000000..35c1e163 --- /dev/null +++ b/test/cloud-catalog-filter.test.js @@ -0,0 +1,532 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { config } from '../src/config.js'; +import { + __resetModelCatalogState, + __setModelCatalogDeps, + __waitForModelCatalogSync, + addAccountByKey, + configureBindHost, + getAvailableModelsForAccount, + getDroughtSummary, + isDroughtMode, + isModelBlockedByDrought, + isModelAllowedForAccount, + removeAccount, + setAccountStatus, + trySyncModelCatalog, +} from '../src/auth.js'; +import { handleDashboardApi } from '../src/dashboard/api.js'; +import { handleModels } from '../src/handlers/models.js'; +import { + MODELS, + MODEL_TIER_ACCESS, + filterModelKeysByCloudCatalog, + listModels, + mergeCloudCatalogSnapshot, + mergeCloudModels, + setActiveCloudCatalogAccounts, +} from '../src/models.js'; + +const ALLOWED_KEY = 'gemini-2.5-flash'; +const SECOND_ALLOWED_KEY = 'claude-4-sonnet'; +const ACCOUNT_A = 'catalog-account-a'; +const ACCOUNT_B = 'catalog-account-b'; +const ORIGINAL_ALLOW_NO_AUTH = process.env.DASHBOARD_ALLOW_NO_AUTH; +const ORIGINAL_IGNORE_FILTER = process.env.WINDSURFAPI_IGNORE_CLOUD_FILTER; +const ORIGINAL_SPECIAL_BACKEND = process.env.WINDSURFAPI_SPECIAL_AGENT_BACKEND; +const ORIGINAL_CLI_ENABLED = process.env.DEVIN_CLI_ENABLED; +const ORIGINAL_DASHBOARD_PASSWORD = config.dashboardPassword; +const ORIGINAL_API_KEY = config.apiKey; +const createdAccountIds = []; + +function fakeRes() { + return { + statusCode: 0, + body: '', + writeHead(status) { this.statusCode = status; }, + end(chunk) { this.body += chunk ? String(chunk) : ''; }, + json() { return this.body ? JSON.parse(this.body) : null; }, + }; +} + +function localReq(path) { + return { + url: `/dashboard/api${path}`, + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + }; +} + +function syncAllowed(keys = [ALLOWED_KEY], accountId = ACCOUNT_A) { + setActiveCloudCatalogAccounts([accountId]); + const configs = keys.map((key) => ({ modelUid: MODELS[key].modelUid })); + confirmCloudCatalog(configs, accountId); +} + +function cascadeKeys(keys) { + return keys.filter((key) => MODELS[key]?.backend !== 'special_agent'); +} + +function fullStaticCloudConfigs() { + const seen = new Set(); + const configs = []; + for (const model of Object.values(MODELS)) { + if (model?.deprecated || model?.backend === 'special_agent' || typeof model?.modelUid !== 'string') continue; + const normalized = model.modelUid.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + configs.push({ modelUid: model.modelUid }); + } + return configs; +} + +function confirmCloudCatalog(configs, accountId) { + // Direct model-layer calls represent two separate sync rounds. Production + // schedules the second round after the confirmation delay. + mergeCloudModels(configs, { accountId }); + mergeCloudModels(configs, { accountId }); +} + +beforeEach(() => { + __resetModelCatalogState(); + __setModelCatalogDeps(null); + delete process.env.WINDSURFAPI_IGNORE_CLOUD_FILTER; + delete process.env.WINDSURFAPI_SPECIAL_AGENT_BACKEND; + delete process.env.DEVIN_CLI_ENABLED; + process.env.DASHBOARD_ALLOW_NO_AUTH = '1'; + config.dashboardPassword = ''; + config.apiKey = ''; + configureBindHost('127.0.0.1'); +}); + +afterEach(async () => { + for (const accountId of createdAccountIds.splice(0)) removeAccount(accountId); + await __waitForModelCatalogSync(); + __resetModelCatalogState(); + __setModelCatalogDeps(null); + if (ORIGINAL_ALLOW_NO_AUTH === undefined) delete process.env.DASHBOARD_ALLOW_NO_AUTH; + else process.env.DASHBOARD_ALLOW_NO_AUTH = ORIGINAL_ALLOW_NO_AUTH; + if (ORIGINAL_IGNORE_FILTER === undefined) delete process.env.WINDSURFAPI_IGNORE_CLOUD_FILTER; + else process.env.WINDSURFAPI_IGNORE_CLOUD_FILTER = ORIGINAL_IGNORE_FILTER; + if (ORIGINAL_SPECIAL_BACKEND === undefined) delete process.env.WINDSURFAPI_SPECIAL_AGENT_BACKEND; + else process.env.WINDSURFAPI_SPECIAL_AGENT_BACKEND = ORIGINAL_SPECIAL_BACKEND; + if (ORIGINAL_CLI_ENABLED === undefined) delete process.env.DEVIN_CLI_ENABLED; + else process.env.DEVIN_CLI_ENABLED = ORIGINAL_CLI_ENABLED; + config.dashboardPassword = ORIGINAL_DASHBOARD_PASSWORD; + config.apiKey = ORIGINAL_API_KEY; + configureBindHost('0.0.0.0'); +}); + +describe('upstream account cloud catalog filtering', () => { + it('limits GET /v1/models and tier routing to catalog-approved Cascade models', () => { + syncAllowed(); + + const apiKeys = handleModels({}).data.map((model) => model._windsurf_id); + assert.deepEqual(cascadeKeys(apiKeys), [ALLOWED_KEY]); + assert.deepEqual(cascadeKeys(MODEL_TIER_ACCESS.pro), [ALLOWED_KEY]); + assert.deepEqual(cascadeKeys(MODEL_TIER_ACCESS.unknown), [ALLOWED_KEY]); + assert.deepEqual(cascadeKeys(MODEL_TIER_ACCESS.free), [ALLOWED_KEY]); + }); + + it('makes the upstream account catalog win over entitlement and manual tier overrides', () => { + syncAllowed(); + const disallowedKey = 'claude-4-sonnet'; + const statusAccount = { + id: ACCOUNT_A, + tier: 'pro', + userStatusLastFetched: Date.now(), + capabilities: { + [ALLOWED_KEY]: { ok: true, reason: 'user_status' }, + [disallowedKey]: { ok: true, reason: 'user_status' }, + }, + }; + const manualAccount = { ...statusAccount, tierManual: true }; + + assert.equal(isModelAllowedForAccount(statusAccount, ALLOWED_KEY), true); + assert.equal( + isModelAllowedForAccount(statusAccount, disallowedKey), + false, + 'per-account entitlement must not bypass the upstream account catalog', + ); + assert.equal( + isModelAllowedForAccount(manualAccount, disallowedKey), + false, + 'manual tier override must not bypass the upstream account catalog', + ); + assert.ok(!getAvailableModelsForAccount(statusAccount).includes(disallowedKey)); + }); + + it('uses the same approved catalog for both Dashboard model endpoints', async () => { + syncAllowed(); + + const modelsRes = fakeRes(); + await handleDashboardApi('GET', '/models', {}, localReq('/models'), modelsRes); + assert.equal(modelsRes.statusCode, 200); + assert.deepEqual( + cascadeKeys(modelsRes.json().models.map((model) => model.id)), + [ALLOWED_KEY], + '/dashboard/api/models must not expose models outside the active account catalogs', + ); + + const tierRes = fakeRes(); + await handleDashboardApi('GET', '/tier-access', {}, localReq('/tier-access'), tierRes); + assert.equal(tierRes.statusCode, 200); + assert.deepEqual(cascadeKeys(tierRes.json().allModels), [ALLOWED_KEY]); + }); + + it('does not expose mutable cloud-catalog state', () => { + syncAllowed(); + + const snapshot = filterModelKeysByCloudCatalog(); + snapshot.length = 0; + + assert.deepEqual( + cascadeKeys(listModels().map((model) => model._windsurf_id)), + [ALLOWED_KEY], + 'mutating a returned snapshot must not disable the active filter', + ); + }); + + it('fails open before a usable catalog arrives and supports an explicit opt-out', () => { + const baseline = handleModels({}).data.map((model) => model.id); + assert.ok(baseline.length > 100); + + setActiveCloudCatalogAccounts([ACCOUNT_A]); + mergeCloudModels([null, {}, { modelUid: 123 }], { accountId: ACCOUNT_A }); + assert.deepEqual(handleModels({}).data.map((model) => model.id), baseline); + + syncAllowed(); + process.env.WINDSURFAPI_IGNORE_CLOUD_FILTER = '1'; + assert.deepEqual(handleModels({}).data.map((model) => model.id), baseline); + }); + + it('clears a stale catalog when mergeCloudModels receives a non-array', () => { + const baseline = handleModels({}).data.map((model) => model.id); + syncAllowed(); + + mergeCloudModels({ malformed: true }, { accountId: ACCOUNT_A }); + + assert.deepEqual(handleModels({}).data.map((model) => model.id), baseline); + }); + + it('matches cloud model UIDs case-insensitively', () => { + setActiveCloudCatalogAccounts([ACCOUNT_A]); + const configs = [{ modelUid: MODELS[ALLOWED_KEY].modelUid.toLowerCase() }]; + confirmCloudCatalog(configs, ACCOUNT_A); + + assert.deepEqual( + cascadeKeys(handleModels({}).data.map((model) => model._windsurf_id)), + [ALLOWED_KEY], + ); + }); + + it('keeps enabled special-agent models because Cascade catalog policy does not govern them', () => { + process.env.WINDSURFAPI_SPECIAL_AGENT_BACKEND = 'devin-cli'; + syncAllowed(); + + const models = listModels(); + assert.deepEqual(cascadeKeys(models.map((model) => model._windsurf_id)), [ALLOWED_KEY]); + assert.ok(models.some((model) => model._backend === 'special_agent' && model._available)); + }); + + it('keeps a newly discovered cloud model when that same catalog allows it', () => { + const uid = 'MODEL_CLOUD_CATALOG_TEST'; + const key = 'model-cloud-catalog-test'; + setActiveCloudCatalogAccounts([ACCOUNT_A]); + const configs = [{ + modelUid: uid, + provider: 'MODEL_PROVIDER_ANTHROPIC', + creditMultiplier: 2, + }]; + confirmCloudCatalog(configs, ACCOUNT_A); + + const models = handleModels({}).data; + assert.deepEqual(cascadeKeys(models.map((model) => model._windsurf_id)), [key]); + assert.equal(models[0].owned_by, 'anthropic'); + }); + + it('unions model listings while enforcing each account catalog during routing', () => { + setActiveCloudCatalogAccounts([ACCOUNT_A, ACCOUNT_B]); + const configsA = [{ modelUid: MODELS[ALLOWED_KEY].modelUid }]; + const configsB = [{ modelUid: MODELS[SECOND_ALLOWED_KEY].modelUid }]; + confirmCloudCatalog(configsA, ACCOUNT_A); + confirmCloudCatalog(configsB, ACCOUNT_B); + + assert.deepEqual( + cascadeKeys(handleModels({}).data.map((model) => model._windsurf_id)).sort(), + [ALLOWED_KEY, SECOND_ALLOWED_KEY].sort(), + 'the pool catalog should be the union of active account catalogs', + ); + + const baseAccount = { + tier: 'pro', + tierManual: true, + capabilities: {}, + }; + const accountA = { ...baseAccount, id: ACCOUNT_A }; + const accountB = { ...baseAccount, id: ACCOUNT_B }; + + assert.equal(isModelAllowedForAccount(accountA, ALLOWED_KEY), true); + assert.equal(isModelAllowedForAccount(accountA, SECOND_ALLOWED_KEY), false); + assert.equal(isModelAllowedForAccount(accountB, ALLOWED_KEY), false); + assert.equal(isModelAllowedForAccount(accountB, SECOND_ALLOWED_KEY), true); + }); + + it('fails global listings open until every active account has a usable catalog', () => { + const baseline = handleModels({}).data.map((model) => model.id); + setActiveCloudCatalogAccounts([ACCOUNT_A, ACCOUNT_B]); + const configs = [{ modelUid: MODELS[ALLOWED_KEY].modelUid }]; + mergeCloudModels(configs, { accountId: ACCOUNT_A }); + + assert.deepEqual(handleModels({}).data.map((model) => model.id), baseline); + }); + + it('synchronizes every active account instead of reusing the first catalog', async () => { + const runId = Date.now().toString(36); + const apiKeyA = `catalog-sync-${runId}-a`; + const apiKeyB = `catalog-sync-${runId}-b`; + const requestedKeys = []; + const scheduledRetries = []; + __setModelCatalogDeps({ + disableConnectSync: true, + scheduleCatalogRetry: (retry) => { + scheduledRetries.push(retry); + return () => {}; + }, + getCascadeModelConfigs: async (apiKey) => { + requestedKeys.push(apiKey); + const modelKey = apiKey === apiKeyA ? ALLOWED_KEY : SECOND_ALLOWED_KEY; + return { configs: [{ modelUid: MODELS[modelKey].modelUid }] }; + }, + }); + + const accountA = addAccountByKey(apiKeyA, 'catalog-a'); + const accountB = addAccountByKey(apiKeyB, 'catalog-b'); + createdAccountIds.push(accountA.id, accountB.id); + accountA.tier = 'pro'; + accountA.tierManual = true; + accountB.tier = 'pro'; + accountB.tierManual = true; + await __waitForModelCatalogSync(); + assert.equal(scheduledRetries.length, 2); + for (const retry of scheduledRetries.splice(0)) retry(); + await __waitForModelCatalogSync(); + + assert.deepEqual(new Set(requestedKeys), new Set([apiKeyA, apiKeyB])); + assert.equal(isModelAllowedForAccount(accountA, ALLOWED_KEY), true); + assert.equal(isModelAllowedForAccount(accountA, SECOND_ALLOWED_KEY), false); + assert.equal(isModelAllowedForAccount(accountB, ALLOWED_KEY), false); + assert.equal(isModelAllowedForAccount(accountB, SECOND_ALLOWED_KEY), true); + + setAccountStatus(accountB.id, 'disabled'); + assert.deepEqual( + cascadeKeys(handleModels({}).data.map((model) => model._windsurf_id)), + [ALLOWED_KEY], + 'inactive account catalogs must not remain in the pool listing', + ); + }); + + it('confirms an anomalously small first snapshot only after a delayed sync round', async () => { + const runId = Date.now().toString(36); + const apiKey = `catalog-partial-${runId}`; + let requests = 0; + let scheduledRetry; + let retryDelay; + const partial = [{ modelUid: MODELS[SECOND_ALLOWED_KEY].modelUid }]; + __setModelCatalogDeps({ + disableConnectSync: true, + scheduleCatalogRetry: (retry, delay) => { + scheduledRetry = retry; + retryDelay = delay; + return () => {}; + }, + getCascadeModelConfigs: async () => { + requests += 1; + return { configs: partial }; + }, + }); + + const account = addAccountByKey(apiKey, 'catalog-partial'); + createdAccountIds.push(account.id); + account.tier = 'pro'; + account.tierManual = true; + await __waitForModelCatalogSync(); + + assert.equal(requests, 1, 'one sync round must issue only one catalog request'); + assert.equal(retryDelay, 30_000); + assert.equal(typeof scheduledRetry, 'function'); + assert.equal(isModelAllowedForAccount(account, ALLOWED_KEY), true); + + trySyncModelCatalog(); + await __waitForModelCatalogSync(); + assert.equal( + requests, + 1, + 'ordinary sync triggers must not bypass the delayed confirmation round', + ); + + scheduledRetry(); + await __waitForModelCatalogSync(); + + assert.equal(requests, 2); + assert.equal(isModelAllowedForAccount(account, ALLOWED_KEY), false); + assert.equal(isModelAllowedForAccount(account, SECOND_ALLOWED_KEY), true); + }); + + it('does not keep polling when the delayed confirmation returns a different candidate', async () => { + const runId = Date.now().toString(36); + const apiKey = `catalog-changing-${runId}`; + const scheduledRetries = []; + let requests = 0; + __setModelCatalogDeps({ + disableConnectSync: true, + scheduleCatalogRetry: (retry) => { + scheduledRetries.push(retry); + return () => {}; + }, + getCascadeModelConfigs: async () => { + requests += 1; + const modelKey = requests === 1 ? ALLOWED_KEY : SECOND_ALLOWED_KEY; + return { configs: [{ modelUid: MODELS[modelKey].modelUid }] }; + }, + }); + + const account = addAccountByKey(apiKey, 'catalog-changing'); + createdAccountIds.push(account.id); + account.tier = 'pro'; + account.tierManual = true; + await __waitForModelCatalogSync(); + + assert.equal(scheduledRetries.length, 1); + scheduledRetries.shift()(); + await __waitForModelCatalogSync(); + + assert.equal(requests, 2); + assert.equal(scheduledRetries.length, 0); + assert.equal(isModelAllowedForAccount(account, ALLOWED_KEY), true); + assert.equal(isModelAllowedForAccount(account, SECOND_ALLOWED_KEY), true); + + setAccountStatus(account.id, 'disabled'); + setAccountStatus(account.id, 'active'); + await __waitForModelCatalogSync(); + + assert.equal(requests, 3); + assert.equal( + scheduledRetries.length, + 1, + 'reactivation must start a new delayed confirmation after stale pending state is cleared', + ); + assert.equal(isModelAllowedForAccount(account, ALLOWED_KEY), true); + assert.equal(isModelAllowedForAccount(account, SECOND_ALLOWED_KEY), true); + }); + + it('keeps the last accepted snapshot until a large shrink is confirmed', () => { + setActiveCloudCatalogAccounts([ACCOUNT_A]); + mergeCloudModels(fullStaticCloudConfigs(), { accountId: ACCOUNT_A }); + + const partial = [{ modelUid: MODELS[SECOND_ALLOWED_KEY].modelUid }]; + // The first shrink is quarantined and preserves the last-known-good catalog. + const firstAdded = mergeCloudModels(partial, { accountId: ACCOUNT_A }); + + assert.equal(firstAdded, 0); + assert.ok( + handleModels({}).data.some((model) => model._windsurf_id === ALLOWED_KEY), + 'an unconfirmed shrink must preserve the last accepted snapshot', + ); + + // A matching snapshot from a later sync round confirms the smaller catalog. + mergeCloudModels(partial, { accountId: ACCOUNT_A }); + assert.deepEqual( + cascadeKeys(handleModels({}).data.map((model) => model._windsurf_id)), + [SECOND_ALLOWED_KEY], + ); + }); + + it('accepts a confirmed small allowlist and fails drought restriction open when no free model remains', async () => { + const runId = Date.now().toString(36); + const apiKey = `catalog-stable-small-${runId}`; + let requests = 0; + let scheduledRetry; + __setModelCatalogDeps({ + disableConnectSync: true, + scheduleCatalogRetry: (retry) => { + scheduledRetry = retry; + return () => {}; + }, + getCascadeModelConfigs: async () => { + requests += 1; + return { configs: [{ modelUid: MODELS[SECOND_ALLOWED_KEY].modelUid }] }; + }, + }); + + const account = addAccountByKey(apiKey, 'catalog-stable-small'); + createdAccountIds.push(account.id); + account.tier = 'pro'; + account.tierManual = true; + account.credits = { weeklyPercent: 0, dailyPercent: 0 }; + await __waitForModelCatalogSync(); + scheduledRetry(); + await __waitForModelCatalogSync(); + + assert.equal(requests, 2); + assert.equal(isModelAllowedForAccount(account, SECOND_ALLOWED_KEY), true); + assert.equal(isModelAllowedForAccount(account, ALLOWED_KEY), false); + assert.equal(isDroughtMode(), true); + assert.equal(isModelBlockedByDrought(SECOND_ALLOWED_KEY), false); + const summary = getDroughtSummary(); + assert.deepEqual(summary.freeTierModels, []); + assert.equal(summary.restrictionFailOpen, true); + }); + + it('leaves a malformed catalog response retryable and accepts the next valid response', async () => { + const runId = Date.now().toString(36); + const apiKey = `catalog-retry-${runId}`; + let requests = 0; + __setModelCatalogDeps({ + disableConnectSync: true, + getCascadeModelConfigs: async () => { + requests += 1; + if (requests === 1) return {}; + return { configs: fullStaticCloudConfigs() }; + }, + }); + + const account = addAccountByKey(apiKey, 'catalog-retry'); + createdAccountIds.push(account.id); + await __waitForModelCatalogSync(); + trySyncModelCatalog(); + await __waitForModelCatalogSync(); + + assert.equal(requests, 2); + assert.equal(isModelAllowedForAccount(account, ALLOWED_KEY), true); + }); + + it('treats a valid empty catalog as synchronized instead of retrying it', async () => { + const runId = Date.now().toString(36); + const apiKey = `catalog-empty-${runId}`; + let requests = 0; + __setModelCatalogDeps({ + disableConnectSync: true, + getCascadeModelConfigs: async () => { + requests += 1; + return { configs: [] }; + }, + }); + + const account = addAccountByKey(apiKey, 'catalog-empty'); + createdAccountIds.push(account.id); + await __waitForModelCatalogSync(); + trySyncModelCatalog(); + await __waitForModelCatalogSync(); + + assert.equal(requests, 1); + }); + + it('labels a valid empty catalog as no_filter', () => { + const snapshot = mergeCloudCatalogSnapshot([], { accountId: ACCOUNT_A }); + + assert.equal(snapshot.accepted, true); + assert.equal(snapshot.reason, 'no_filter'); + }); +}); diff --git a/test/dashboard-syntax.test.js b/test/dashboard-syntax.test.js index 868ac262..9f9b203c 100644 --- a/test/dashboard-syntax.test.js +++ b/test/dashboard-syntax.test.js @@ -51,6 +51,20 @@ test('dashboard batch login history uses each result proxy instead of an undefin assert.doesNotMatch(html, /proxy:\s*this\.getWindsurfProxyLabel\(proxy\),\s*\r?\n\s*status:\s*item\.success/); }); +test('dashboard drought banners expose restriction fail-open state', () => { + const html = readFileSync(join(root, 'src/dashboard/index.html'), 'utf8'); + const sketch = readFileSync(join(root, 'src/dashboard/index-sketch.html'), 'utf8'); + const en = JSON.parse(readFileSync(join(root, 'src/dashboard/i18n/en.json'), 'utf8')); + const zh = JSON.parse(readFileSync(join(root, 'src/dashboard/i18n/zh-CN.json'), 'utf8')); + + assert.match(html, /d\.restrictionFailOpen/); + assert.match(html, /I18n\.t\('drought\.restrictionFailOpen'\)/); + assert.match(sketch, /d\.restrictionFailOpen/); + assert.match(sketch, /id="drought-fail-open-message"/); + assert.equal(typeof en.drought.restrictionFailOpen, 'string'); + assert.equal(typeof zh.drought.restrictionFailOpen, 'string'); +}); + test('dashboard proxy and abnormal-account tables use paged account summaries', () => { const html = readFileSync(join(root, 'src/dashboard/index.html'), 'utf8'); assert.match(html, /id="proxy-accounts-pagination"/); diff --git a/test/drought-restrict-premium.test.js b/test/drought-restrict-premium.test.js index 0328fd70..c58ccc73 100644 --- a/test/drought-restrict-premium.test.js +++ b/test/drought-restrict-premium.test.js @@ -106,6 +106,7 @@ describe('getDroughtSummary includes restriction state (v2.0.58)', () => { mk('a', { weeklyPercent: 50, dailyPercent: 50 }); const s = getDroughtSummary(); assert.equal(typeof s.restrictEnabled, 'boolean'); + assert.equal(s.restrictionFailOpen, false); assert.ok(Array.isArray(s.freeTierModels)); assert.ok(s.freeTierModels.includes('gemini-2.5-flash'), `freeTierModels should include gemini-2.5-flash, got ${JSON.stringify(s.freeTierModels)}`);