diff --git a/routes/model_routes.py b/routes/model_routes.py
index 1901901e0..f27363a60 100644
--- a/routes/model_routes.py
+++ b/routes/model_routes.py
@@ -28,6 +28,7 @@ from src.endpoint_resolver import (
build_chat_url,
build_models_url,
build_headers,
+ resolve_endpoint_runtime,
)
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
@@ -1566,6 +1567,16 @@ def setup_model_routes(model_discovery):
base = _normalize_base(ep.base_url)
provider = _safe_detect_provider(base)
ep_model_type = getattr(ep, "model_type", None) or "llm"
+ configured_model_ids = _merge_model_ids(
+ _cached_model_ids(ep),
+ _normalize_model_ids(getattr(ep, "pinned_models", None)),
+ _normalize_model_ids(getattr(ep, "hidden_models", None)),
+ )
+ model_ids = _visible_models(
+ _cached_model_ids(ep),
+ ep.hidden_models,
+ getattr(ep, "pinned_models", None),
+ )
# Build correct URL based on provider
chat_url = build_chat_url(base)
kind = _effective_endpoint_kind(ep, base)
@@ -1594,6 +1605,7 @@ def setup_model_routes(model_discovery):
"category": category,
"endpoint_kind": kind,
"model_type": ep_model_type,
+ "_foreground_model_catalog_unknown": False,
})
else:
# Endpoint unreachable but still show it greyed out
@@ -1611,12 +1623,147 @@ def setup_model_routes(model_discovery):
"endpoint_kind": kind,
"model_type": ep_model_type,
"offline": True,
+ "_foreground_model_catalog_unknown": not bool(configured_model_ids),
})
return {"hosts": [], "items": items}
+ def _foreground_catalog_allowed_models(request: Request, owner: str):
+ """Return the caller's foreground model allowlist, if restricted."""
+
+ if not owner:
+ return None
+ auth_mgr = getattr(getattr(request, "app", None), "state", None)
+ auth_mgr = getattr(auth_mgr, "auth_manager", None)
+ if auth_mgr is None:
+ return None
+ try:
+ privileges = auth_mgr.get_privileges(owner) or {}
+ except Exception:
+ # The runtime policy fails closed if it cannot resolve privileges.
+ # Keep the editor equally conservative instead of offering models
+ # whose credentials it may not be allowed to resolve.
+ return frozenset()
+ if privileges.get("block_all_models"):
+ return frozenset()
+ raw = privileges.get("allowed_models")
+ allowed = raw if isinstance(raw, list) else []
+ restricted = bool(privileges.get("allowed_models_restricted")) or bool(allowed)
+ if not restricted:
+ return None
+ return frozenset(model for model in allowed if isinstance(model, str))
+
+ def _foreground_runtime_endpoint_ids(
+ owner: str,
+ candidate_endpoint_ids: set[str],
+ ) -> set[str]:
+ """Return owner-visible LLM endpoints whose runtime route resolves now."""
+
+ if not candidate_endpoint_ids:
+ return set()
+ db = SessionLocal()
+ try:
+ query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
+ if owner:
+ query = owner_filter(query, ModelEndpoint, owner)
+ endpoint_ids = set()
+ for endpoint in query.all():
+ if endpoint.id not in candidate_endpoint_ids:
+ continue
+ if (getattr(endpoint, "model_type", None) or "llm") != "llm":
+ continue
+ try:
+ base_url, _api_key = resolve_endpoint_runtime(
+ endpoint,
+ owner=owner or None,
+ )
+ except Exception:
+ # Match exact fallback resolution: a provider-auth route
+ # whose current credentials cannot resolve is not offered
+ # as an eligible concrete candidate. Never expose the
+ # credential failure or secret material in this feed.
+ continue
+ if base_url:
+ endpoint_ids.add(endpoint.id)
+ return endpoint_ids
+ finally:
+ db.close()
+
+ def _foreground_catalog_result(
+ result: dict,
+ allowed_models,
+ owner: str,
+ ) -> dict:
+ """Copy the safe model feed and apply foreground-only constraints."""
+
+ if allowed_models is not None and not allowed_models:
+ return {"hosts": [], "items": []}
+ prepared_items = []
+ runtime_candidates = set()
+ for source in result.get("items", []):
+ if not isinstance(source, dict):
+ continue
+ if (source.get("model_type") or "llm") != "llm":
+ continue
+ item = dict(source)
+ catalog_unknown = item.pop(
+ "_foreground_model_catalog_unknown",
+ False,
+ ) is True
+ for key, display_key in (
+ ("models", "models_display"),
+ ("models_extra", "models_extra_display"),
+ ):
+ models = item.get(key) if isinstance(item.get(key), list) else []
+ if allowed_models is not None:
+ models = [model for model in models if model in allowed_models]
+ item[key] = models
+ item[display_key] = [_model_display_name(model) for model in models]
+ item["model_catalog_unknown"] = catalog_unknown
+ item["allowed_unknown_models"] = (
+ sorted(allowed_models)
+ if catalog_unknown and allowed_models is not None
+ else None
+ )
+ potentially_eligible = bool(
+ item.get("models")
+ or item.get("models_extra")
+ or catalog_unknown
+ )
+ if potentially_eligible:
+ runtime_candidates.add(item.get("endpoint_id"))
+ prepared_items.append((item, potentially_eligible))
+ runtime_endpoint_ids = _foreground_runtime_endpoint_ids(
+ owner,
+ runtime_candidates,
+ )
+ items = [
+ item
+ for item, potentially_eligible in prepared_items
+ if not potentially_eligible
+ or item.get("endpoint_id") in runtime_endpoint_ids
+ ]
+ return {"hosts": [], "items": items}
+
+ def _public_model_result(result: dict) -> dict:
+ """Strip foreground-only cache metadata from the ordinary picker feed."""
+
+ items = []
+ for source in result.get("items", []):
+ if not isinstance(source, dict):
+ continue
+ item = dict(source)
+ item.pop("_foreground_model_catalog_unknown", None)
+ items.append(item)
+ return {"hosts": result.get("hosts", []), "items": items}
+
@router.get("/models")
- def api_models(request: Request, refresh: bool = False, background: bool = False):
+ def api_models(
+ request: Request,
+ refresh: bool = False,
+ background: bool = False,
+ foreground_fallback: bool = False,
+ ):
"""Get available models — per-user (caller sees only their endpoints +
legacy/shared null-owner rows). Cached per-user for 30s."""
# Require auth; "" is the unconfigured single-user mode, treated as
@@ -1652,18 +1799,32 @@ def setup_model_routes(model_discovery):
now = _time.time()
# Cache key includes the admin flag so a demotion / promotion doesn't
# serve the wrong scoped view from cache.
- _cache_key = (owner, _is_admin)
+ # The foreground Settings editor must mirror the runtime resolver's
+ # exact owner boundary, including for admins. The ordinary picker keeps
+ # its historical admin-wide inventory behavior.
+ catalog_is_admin = _is_admin and not foreground_fallback
+ _cache_key = (owner, catalog_is_admin)
cache_entry = _models_cache.get(_cache_key)
if not refresh and cache_entry is not None and (now - cache_entry["time"]) < _MODELS_CACHE_TTL:
- return cache_entry["data"]
- result = _fetch_models(owner=owner, is_admin=_is_admin)
- _models_cache[_cache_key] = {"data": result, "time": now}
+ result = cache_entry["data"]
+ else:
+ result = _fetch_models(owner=owner, is_admin=catalog_is_admin)
+ _models_cache[_cache_key] = {"data": result, "time": now}
# Kick off background refresh to update caches from live endpoints.
# Page boot can opt out with background=false so opening Odysseus does
# not start endpoint probes against slow/offline model servers.
if background or refresh:
_refresh_caches_bg(force=refresh)
- return result
+ if foreground_fallback:
+ allowed_models = _foreground_catalog_allowed_models(request, owner)
+ if allowed_models is not None and not allowed_models:
+ return {"hosts": [], "items": []}
+ return _foreground_catalog_result(
+ result,
+ allowed_models,
+ owner,
+ )
+ return _public_model_result(result)
# Brief cache for local-probe results so picker-open doesn't hammer
# endpoint health checks every time. 8s TTL — long enough to amortize cost,
diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py
index 406abd5f9..17abd70a5 100644
--- a/src/endpoint_resolver.py
+++ b/src/endpoint_resolver.py
@@ -150,6 +150,25 @@ def _endpoint_enabled_models(ep) -> list:
return [m for m in merged if m not in hidden]
+def _endpoint_has_known_model_inventory(ep) -> bool:
+ """Return whether endpoint configuration proves its catalog is known.
+
+ Hidden model ids remain evidence of a configured inventory even when every
+ cached or pinned model is hidden. Exact foreground resolution must not
+ reinterpret that state as an unknown catalog where arbitrary model ids are
+ accepted.
+ """
+
+ return any(
+ isinstance(model, str) and bool(model.strip())
+ for model in [
+ *_endpoint_cached_models(ep),
+ *_endpoint_pinned_models(ep),
+ *_endpoint_hidden_models(ep),
+ ]
+ )
+
+
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
"""Resolve a ModelEndpoint row to its runtime base URL and bearer/API key.
@@ -447,6 +466,7 @@ def _resolve_endpoint_by_id_with_descriptor(
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
+ required_model_type: Optional[str] = None,
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
@@ -467,6 +487,10 @@ def _resolve_endpoint_by_id_with_descriptor(
ep = q.first()
if not ep:
return None
+ if required_model_type:
+ model_type = getattr(ep, "model_type", None) or "llm"
+ if model_type != required_model_type:
+ return None
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
@@ -482,7 +506,7 @@ def _resolve_endpoint_by_id_with_descriptor(
# silently substituting another model from the endpoint.
if not m or m in _endpoint_hidden_models(ep):
return None
- if enabled_models and m not in enabled_models:
+ if _endpoint_has_known_model_inventory(ep) and m not in enabled_models:
return None
else:
# Legacy Utility/Vision chains retain their model-repair behavior.
@@ -626,6 +650,7 @@ def resolve_fallback_entries_with_descriptors(
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
+ required_model_type: Optional[str] = None,
) -> list:
"""Resolve ordered entries while retaining safe endpoint provenance."""
@@ -639,6 +664,7 @@ def resolve_fallback_entries_with_descriptors(
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
+ required_model_type=required_model_type,
)
if not resolved:
continue
diff --git a/src/foreground_model_routing.py b/src/foreground_model_routing.py
index 2f437e0f5..98223b5af 100644
--- a/src/foreground_model_routing.py
+++ b/src/foreground_model_routing.py
@@ -116,6 +116,7 @@ def resolve_foreground_model_policy(
entries,
owner=owner,
require_exact_model=True,
+ required_model_type="llm",
)
candidates = [candidate for candidate, _descriptor in resolved_routes]
if not candidates:
diff --git a/static/index.html b/static/index.html
index 15e934b28..8ad643a27 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1482,6 +1482,28 @@
+
+
+ Allow fallback when the selected model is unavailable
+
+
+
+
+
+
Only availability failures—connection or read timeouts, rate limits, and documented upstream outages—try the ordered list. Authentication, authorization, request, unsupported-model, and configuration errors are shown directly.
+
Loading fallback settings…
+
+
+
Retry loading fallback settings
+
diff --git a/static/js/foregroundFallbackSettings.js b/static/js/foregroundFallbackSettings.js
new file mode 100644
index 000000000..209285cb2
--- /dev/null
+++ b/static/js/foregroundFallbackSettings.js
@@ -0,0 +1,210 @@
+// Pure state helpers for the per-user foreground fallback Settings controls.
+
+export const MAX_FOREGROUND_FALLBACKS = 10;
+
+export function createForegroundPreferenceSaveQueue(write, onUnavailable) {
+ let queue = Promise.resolve();
+
+ return {
+ save(key, value) {
+ queue = queue.then(async () => {
+ try {
+ await write(key, value);
+ } catch (error) {
+ if (typeof onUnavailable === 'function') onUnavailable(error);
+ throw error;
+ }
+ });
+ return queue;
+ },
+ reset() {
+ queue = Promise.resolve();
+ },
+ };
+}
+
+export function cleanForegroundFallbackCandidates(
+ value,
+ maxItems = MAX_FOREGROUND_FALLBACKS
+) {
+ if (!Array.isArray(value)) return [];
+ const limit = Number.isInteger(maxItems) && maxItems >= 0
+ ? maxItems
+ : MAX_FOREGROUND_FALLBACKS;
+ const seen = new Set();
+ return value
+ .filter(item => item && typeof item === 'object')
+ .map(item => ({
+ endpoint_id: typeof item.endpoint_id === 'string' ? item.endpoint_id : '',
+ model: typeof item.model === 'string' ? item.model : '',
+ }))
+ .filter(item => item.endpoint_id && item.model)
+ .filter(item => {
+ const identity = JSON.stringify([item.endpoint_id, item.model]);
+ if (seen.has(identity)) return false;
+ seen.add(identity);
+ return true;
+ })
+ .slice(0, limit);
+}
+
+export function nextForegroundFallbackCandidate(endpoints, value) {
+ const used = new Set(
+ cleanForegroundFallbackCandidates(value).map(item =>
+ JSON.stringify([item.endpoint_id, item.model])
+ )
+ );
+ for (const endpoint of Array.isArray(endpoints) ? endpoints : []) {
+ if (!endpoint || typeof endpoint !== 'object' || endpoint.is_enabled === false) {
+ continue;
+ }
+ const endpointId = typeof endpoint.id === 'string' ? endpoint.id : '';
+ if (!endpointId) continue;
+ for (const model of Array.isArray(endpoint.models) ? endpoint.models : []) {
+ if (typeof model !== 'string' || !model) continue;
+ const identity = JSON.stringify([endpointId, model]);
+ if (!used.has(identity)) return { endpoint_id: endpointId, model };
+ }
+ }
+ return null;
+}
+
+export function summarizeForegroundFallbackCandidateEligibility(value, endpoints) {
+ const byEndpoint = new Map();
+ for (const endpoint of Array.isArray(endpoints) ? endpoints : []) {
+ if (!endpoint || typeof endpoint !== 'object' || endpoint.is_enabled === false) {
+ continue;
+ }
+ const endpointId = typeof endpoint.id === 'string' ? endpoint.id : '';
+ if (!endpointId) continue;
+ const models = new Set(
+ (Array.isArray(endpoint.models) ? endpoint.models : [])
+ .filter(model => typeof model === 'string' && model)
+ );
+ const allowedUnknownModels = Array.isArray(endpoint.allowed_unknown_models)
+ ? new Set(endpoint.allowed_unknown_models.filter(model =>
+ typeof model === 'string' && model
+ ))
+ : null;
+ byEndpoint.set(endpointId, {
+ models,
+ catalogUnknown: endpoint.model_catalog_unknown === true,
+ allowedUnknownModels,
+ });
+ }
+ const summary = { configured: 0, eligible: 0, unknown: 0, ineligible: 0 };
+ for (const item of cleanForegroundFallbackCandidates(value)) {
+ summary.configured += 1;
+ const endpoint = byEndpoint.get(item.endpoint_id);
+ if (!endpoint) summary.ineligible += 1;
+ else if (endpoint.models.has(item.model)) summary.eligible += 1;
+ else if (
+ endpoint.catalogUnknown
+ && (
+ endpoint.allowedUnknownModels === null
+ || endpoint.allowedUnknownModels.has(item.model)
+ )
+ ) summary.unknown += 1;
+ else summary.ineligible += 1;
+ }
+ return summary;
+}
+
+export function countEligibleForegroundFallbackCandidates(value, endpoints) {
+ return summarizeForegroundFallbackCandidateEligibility(value, endpoints).eligible;
+}
+
+export function captureFallbackWidgetFocus(container, activeElement) {
+ if (!container || !activeElement || !container.contains(activeElement)) return null;
+ const row = typeof activeElement.closest === 'function'
+ ? activeElement.closest('.settings-fallback-row')
+ : null;
+ const focusKey = activeElement.dataset && activeElement.dataset.fallbackFocus;
+ if (!row || !focusKey) return null;
+ const index = Array.prototype.indexOf.call(container.children || [], row);
+ return index >= 0 ? { index, focusKey } : null;
+}
+
+export function restoreFallbackWidgetFocus(container, addButton, state) {
+ if (!container || !state) return false;
+ const rows = Array.from(container.children || []);
+ if (!rows.length) {
+ if (!addButton || typeof addButton.focus !== 'function') return false;
+ addButton.focus();
+ return true;
+ }
+ const index = Math.max(0, Math.min(Number(state.index) || 0, rows.length - 1));
+ const controls = typeof rows[index].querySelectorAll === 'function'
+ ? Array.from(rows[index].querySelectorAll('[data-fallback-focus]'))
+ : [];
+ const target = controls.find(control =>
+ control.dataset && control.dataset.fallbackFocus === state.focusKey
+ ) || addButton;
+ if (!target || typeof target.focus !== 'function') return false;
+ target.focus();
+ return true;
+}
+
+export function normalizeForegroundFallbackPrefs(prefs) {
+ const source = prefs && typeof prefs === 'object' && !Array.isArray(prefs)
+ ? prefs
+ : {};
+ return {
+ enabled: source.foreground_fallback_enabled === true,
+ candidates: cleanForegroundFallbackCandidates(
+ source.foreground_model_fallbacks
+ ),
+ };
+}
+
+export function moveForegroundFallbackCandidate(value, index, offset) {
+ const candidates = cleanForegroundFallbackCandidates(value);
+ const target = Number(index) + Number(offset);
+ if (
+ !Number.isInteger(index)
+ || !Number.isInteger(offset)
+ || target < 0
+ || target >= candidates.length
+ ) {
+ return candidates;
+ }
+ const next = candidates.slice();
+ const moved = next.splice(index, 1)[0];
+ next.splice(target, 0, moved);
+ return next;
+}
+
+export function normalizeForegroundFallbackModelCatalog(payload) {
+ const items = payload && Array.isArray(payload.items) ? payload.items : [];
+ return items
+ .filter(item => item && typeof item === 'object')
+ .filter(item => !item.model_type || item.model_type === 'llm')
+ .map(item => {
+ const endpointId = typeof item.endpoint_id === 'string'
+ ? item.endpoint_id.trim()
+ : '';
+ const endpointName = typeof item.endpoint_name === 'string'
+ ? item.endpoint_name.trim()
+ : '';
+ const models = [];
+ [...(Array.isArray(item.models) ? item.models : []),
+ ...(Array.isArray(item.models_extra) ? item.models_extra : [])]
+ .forEach(model => {
+ if (typeof model !== 'string') return;
+ const clean = model.trim();
+ if (clean && !models.includes(clean)) models.push(clean);
+ });
+ return {
+ id: endpointId,
+ name: endpointName || 'Model endpoint',
+ is_enabled: true,
+ models,
+ online: item.offline !== true,
+ model_catalog_unknown: item.model_catalog_unknown === true,
+ allowed_unknown_models: Array.isArray(item.allowed_unknown_models)
+ ? item.allowed_unknown_models.filter(model => typeof model === 'string')
+ : null,
+ };
+ })
+ .filter(item => item.id);
+}
diff --git a/static/js/settings.js b/static/js/settings.js
index 3c6e30b44..8e162c5a5 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -9,6 +9,18 @@ import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
+import {
+ captureFallbackWidgetFocus,
+ cleanForegroundFallbackCandidates,
+ createForegroundPreferenceSaveQueue,
+ MAX_FOREGROUND_FALLBACKS,
+ moveForegroundFallbackCandidate,
+ nextForegroundFallbackCandidate,
+ normalizeForegroundFallbackModelCatalog,
+ normalizeForegroundFallbackPrefs,
+ restoreFallbackWidgetFocus,
+ summarizeForegroundFallbackCandidateEligibility,
+} from './foregroundFallbackSettings.js';
let initialized = false;
let modalEl = null;
@@ -204,14 +216,25 @@ function initOpacityToggle() {
═══════════════════════════════════════════ */
const _aiEndpointRefreshers = new Set();
+const _foregroundFallbackEndpointRefreshers = new Set();
let _aiEndpointRefreshInFlight = null;
async function _fetchModelEndpoints() {
const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
+ if (!epRes.ok) throw new Error('HTTP ' + epRes.status);
const endpoints = await epRes.json();
return Array.isArray(endpoints) ? endpoints : [];
}
+async function _fetchForegroundFallbackEndpoints() {
+ const response = await fetch(
+ '/api/models?background=false&foreground_fallback=true',
+ { credentials: 'same-origin' }
+ );
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ return normalizeForegroundFallbackModelCatalog(await response.json());
+}
+
function _endpointLabel(ep) {
return ep.name + (ep.online ? '' : ' (offline)');
}
@@ -309,6 +332,10 @@ function _registerAiEndpointRefresh(fn) {
_aiEndpointRefreshers.add(fn);
}
+function _registerForegroundFallbackEndpointRefresh(fn) {
+ _foregroundFallbackEndpointRefreshers.add(fn);
+}
+
export async function refreshAiModelEndpoints() {
if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight;
_aiEndpointRefreshInFlight = (async function() {
@@ -319,6 +346,17 @@ export async function refreshAiModelEndpoints() {
});
} catch (e) {
console.warn('[settings] failed to refresh model endpoints', e);
+ }
+ try {
+ const endpoints = await _fetchForegroundFallbackEndpoints();
+ _foregroundFallbackEndpointRefreshers.forEach(function(fn) {
+ try { fn(endpoints, null); } catch (e) { console.warn('[settings] foreground fallback endpoint refresh handler failed', e); }
+ });
+ } catch (e) {
+ console.warn('[settings] failed to refresh foreground fallback endpoints', e);
+ _foregroundFallbackEndpointRefreshers.forEach(function(fn) {
+ try { fn(null, e); } catch (handlerError) { console.warn('[settings] foreground fallback endpoint refresh handler failed', handlerError); }
+ });
} finally {
_aiEndpointRefreshInFlight = null;
}
@@ -337,11 +375,61 @@ function _bindFallbackWidget(opts) {
var endpointsRef = opts.endpoints; // mutable list reference
var modelsFilter = opts.modelsFilter || function() { return true; };
var settingKey = opts.settingKey;
- var current = opts.initial || []; // [{endpoint_id, model}]
+ var maxItems = Number.isInteger(opts.maxItems) ? opts.maxItems : null;
+ var current = opts.cleanCandidates
+ ? cleanForegroundFallbackCandidates(
+ opts.initial || [],
+ maxItems === null ? MAX_FOREGROUND_FALLBACKS : maxItems
+ )
+ : (opts.initial || []).slice();
+ var allowReorder = opts.allowReorder === true;
+ var controlsDisabled = opts.disabled === true;
+ var saveQueue = Promise.resolve();
- if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} };
+ if (!fbContainer || !addBtn) return {
+ setEndpoints: function() {},
+ setInitial: function() {},
+ setDisabled: function() {},
+ };
function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); }
+ function selectableEps() {
+ if (!opts.requireModel) return enabledEps();
+ return enabledEps().filter(function(ep) {
+ return Array.isArray(ep.models) && ep.models.some(function(model) {
+ return modelsFilter(model, ep);
+ });
+ });
+ }
+ function firstSelectableEndpoint() {
+ return selectableEps()[0];
+ }
+
+ function firstUnusedCandidate() {
+ return nextForegroundFallbackCandidate(
+ selectableEps().map(function(ep) {
+ return {
+ id: ep.id,
+ is_enabled: ep.is_enabled,
+ models: sortModelIds(ep.models).filter(function(model) {
+ return modelsFilter(model, ep);
+ }),
+ };
+ }),
+ current
+ );
+ }
+
+ function currentForSave() {
+ return opts.cleanCandidates
+ ? cleanForegroundFallbackCandidates(
+ current,
+ maxItems === null ? MAX_FOREGROUND_FALLBACKS : maxItems
+ )
+ : current.filter(function(item) {
+ return item && item.endpoint_id && item.model;
+ });
+ }
function fillModels(selectEl, epId, selected) {
while (selectEl.options.length) selectEl.remove(0);
@@ -355,22 +443,57 @@ function _bindFallbackWidget(opts) {
selectEl.appendChild(o);
});
}
+ if (
+ opts.preserveUnavailable
+ && selected
+ && !Array.from(selectEl.options).some(function(o) { return o.value === selected; })
+ ) {
+ var unavailable = document.createElement('option');
+ unavailable.value = selected;
+ unavailable.textContent = selected.split('/').pop() + ' (unavailable)';
+ unavailable.disabled = true;
+ selectEl.appendChild(unavailable);
+ }
if (selected) selectEl.value = selected;
}
- async function save() {
- var clean = current.filter(function(f) { return f.endpoint_id && f.model; });
- var body = {};
- body[settingKey] = clean;
- try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
+ async function persist() {
+ var clean = currentForSave();
+ if (typeof opts.saveList === 'function') {
+ await opts.saveList(clean);
+ } else {
+ var body = {};
+ body[settingKey] = clean;
+ var response = await fetch('/api/auth/settings', {
+ method: 'POST',
+ credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body)
+ body: JSON.stringify(body),
});
- } catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ }
+ if (typeof opts.onSaved === 'function') opts.onSaved(clean);
+ }
+
+ function save() {
+ saveQueue = saveQueue
+ .catch(function() {})
+ .then(persist)
+ .catch(function(error) {
+ if (typeof opts.onError === 'function') opts.onError(error);
+ else console.warn('[fallback] save failed for ' + settingKey, error);
+ });
+ return saveQueue;
}
function render() {
+ var focusState = captureFallbackWidgetFocus(fbContainer, document.activeElement);
+ if (opts.cleanCandidates) {
+ current = cleanForegroundFallbackCandidates(
+ current,
+ maxItems === null ? MAX_FOREGROUND_FALLBACKS : maxItems
+ );
+ }
fbContainer.innerHTML = '';
current.forEach(function(fb, idx) {
var row = document.createElement('div');
@@ -382,17 +505,34 @@ function _bindFallbackWidget(opts) {
var epS = document.createElement('select');
epS.className = 'settings-select';
- enabledEps().forEach(function(ep) {
+ epS.dataset.fallbackFocus = 'endpoint';
+ epS.disabled = controlsDisabled;
+ epS.setAttribute('aria-label', 'Fallback ' + (idx + 1) + ' endpoint');
+ selectableEps().forEach(function(ep) {
var o = document.createElement('option');
o.value = ep.id;
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
epS.appendChild(o);
});
- var first = enabledEps()[0];
+ if (
+ opts.preserveUnavailable
+ && fb.endpoint_id
+ && !Array.from(epS.options).some(function(o) { return o.value === fb.endpoint_id; })
+ ) {
+ var unavailable = document.createElement('option');
+ unavailable.value = fb.endpoint_id;
+ unavailable.textContent = 'Unavailable endpoint';
+ unavailable.disabled = true;
+ epS.appendChild(unavailable);
+ }
+ var first = firstSelectableEndpoint();
epS.value = fb.endpoint_id || (first ? first.id : '');
var mS = document.createElement('select');
mS.className = 'settings-select';
+ mS.dataset.fallbackFocus = 'model';
+ mS.disabled = controlsDisabled;
+ mS.setAttribute('aria-label', 'Fallback ' + (idx + 1) + ' model');
fillModels(mS, epS.value, fb.model);
fb.endpoint_id = epS.value;
@@ -402,14 +542,22 @@ function _bindFallbackWidget(opts) {
fb.endpoint_id = epS.value;
fillModels(mS, epS.value, '');
fb.model = mS.value;
+ if (opts.cleanCandidates) render();
+ save();
+ });
+ mS.addEventListener('change', function() {
+ fb.model = mS.value;
+ if (opts.cleanCandidates) render();
save();
});
- mS.addEventListener('change', function() { fb.model = mS.value; save(); });
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
+ rm.dataset.fallbackFocus = 'remove';
+ rm.disabled = controlsDisabled;
rm.title = 'Remove fallback';
+ rm.setAttribute('aria-label', 'Remove fallback ' + (idx + 1));
rm.innerHTML = ' ';
rm.addEventListener('click', function() {
current.splice(idx, 1);
@@ -420,14 +568,81 @@ function _bindFallbackWidget(opts) {
row.appendChild(num);
row.appendChild(epS);
row.appendChild(mS);
- row.appendChild(rm);
+ if (allowReorder) {
+ var actions = document.createElement('span');
+ actions.className = 'settings-fallback-actions';
+ [
+ {
+ offset: -1,
+ title: 'Move fallback up',
+ path: ' ',
+ },
+ {
+ offset: 1,
+ title: 'Move fallback down',
+ path: ' ',
+ },
+ ].forEach(function(action) {
+ var move = document.createElement('button');
+ move.type = 'button';
+ move.className = 'settings-fallback-remove settings-fallback-move';
+ var destination = idx + action.offset;
+ var inBounds = destination >= 0 && destination < current.length;
+ var accessibleTitle = inBounds
+ ? action.title + ' ' + (idx + 1) + ' to position ' + (destination + 1)
+ : action.title + ' ' + (idx + 1)
+ + (action.offset < 0 ? ' (already first)' : ' (already last)');
+ move.title = accessibleTitle;
+ move.setAttribute('aria-label', accessibleTitle);
+ move.dataset.moveOffset = String(action.offset);
+ move.dataset.fallbackFocus = action.offset < 0 ? 'move-up' : 'move-down';
+ move.disabled = controlsDisabled || !inBounds;
+ move.innerHTML = '' + action.path + ' ';
+ move.addEventListener('click', function() {
+ var from = idx;
+ var to = idx + action.offset;
+ current = moveForegroundFallbackCandidate(current, idx, action.offset);
+ render();
+ var movedRow = fbContainer.children[to];
+ if (movedRow) {
+ var focusTarget = movedRow.querySelector(
+ '.settings-fallback-move[data-move-offset="' + action.offset + '"]:not(:disabled)'
+ ) || movedRow.querySelector('.settings-fallback-move:not(:disabled)')
+ || movedRow.querySelector('select');
+ if (focusTarget) focusTarget.focus();
+ }
+ if (typeof opts.onReordered === 'function') {
+ opts.onReordered({ from: from, to: to, total: current.length });
+ }
+ save();
+ });
+ actions.appendChild(move);
+ });
+ actions.appendChild(rm);
+ row.appendChild(actions);
+ } else {
+ row.appendChild(rm);
+ }
fbContainer.appendChild(row);
});
+ var unusedCandidate = firstUnusedCandidate();
+ if (opts.manageAddDisabled) {
+ addBtn.disabled = controlsDisabled || !unusedCandidate
+ || (maxItems !== null && current.length >= maxItems);
+ }
+ if (typeof opts.onRender === 'function') {
+ opts.onRender(currentForSave());
+ }
+ restoreFallbackWidgetFocus(fbContainer, addBtn, focusState);
}
addBtn.addEventListener('click', function() {
- var first = enabledEps()[0];
- current.push({ endpoint_id: first ? first.id : '', model: '' });
+ var unusedCandidate = firstUnusedCandidate();
+ if (
+ (opts.manageAddDisabled && !unusedCandidate)
+ || (maxItems !== null && current.length >= maxItems)
+ ) return;
+ current.push(unusedCandidate || { endpoint_id: '', model: '' });
render();
save();
});
@@ -435,8 +650,21 @@ function _bindFallbackWidget(opts) {
render();
return {
- setInitial: function(list) { current = (list || []).slice(); render(); },
+ setInitial: function(list) {
+ current = opts.cleanCandidates
+ ? cleanForegroundFallbackCandidates(
+ list,
+ maxItems === null ? MAX_FOREGROUND_FALLBACKS : maxItems
+ )
+ : (list || []).slice();
+ render();
+ },
refresh: render,
+ getCurrent: currentForSave,
+ setDisabled: function(disabled) {
+ controlsDisabled = disabled === true;
+ render();
+ },
};
}
@@ -445,7 +673,141 @@ async function initDefaultChat() {
var epSel = el('set-defaultEpSelect');
var modelSel = el('set-defaultModelSelect');
var msg = el('set-defaultChatMsg');
+ var fallbackToggle = el('set-foregroundFallbackToggle');
+ var fallbackEditor = el('set-foregroundFallbackEditor');
+ var fallbackState = el('set-foregroundFallbackState');
+ var fallbackMsg = el('set-foregroundFallbackMsg');
+ var fallbackRetry = el('set-foregroundFallbackRetry');
var _endpoints = [];
+ var _foregroundFallbackEndpoints = [];
+ var fallbackWidget = null;
+ var fallbackPrefsLoaded = false;
+ var fallbackCatalogLoaded = false;
+
+ if (fallbackToggle) fallbackToggle.disabled = true;
+ if (fallbackState) fallbackState.textContent = 'Loading fallback settings…';
+
+ function showFallbackMessage(message, failed) {
+ if (!fallbackMsg) return;
+ fallbackMsg.textContent = message;
+ fallbackMsg.style.color = failed ? 'var(--red)' : 'var(--fg)';
+ if (!failed && message) {
+ setTimeout(function() {
+ if (fallbackMsg.textContent === message) fallbackMsg.textContent = '';
+ }, 1800);
+ }
+ }
+
+ var fallbackPreferenceWriter = createForegroundPreferenceSaveQueue(
+ async function(key, value) {
+ var response = await fetch('/api/prefs/' + key, {
+ method: 'PUT',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ value: value }),
+ });
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ },
+ function() {
+ fallbackPrefsLoaded = false;
+ if (fallbackToggle) fallbackToggle.disabled = true;
+ if (fallbackWidget && fallbackWidget.setDisabled) fallbackWidget.setDisabled(true);
+ syncFallbackRetry();
+ applyFallbackVisibility();
+ }
+ );
+
+ function saveForegroundPref(key, value) {
+ return fallbackPreferenceWriter.save(key, value);
+ }
+
+ function applyFallbackVisibility() {
+ if (!fallbackToggle || !fallbackEditor) return;
+ if (!fallbackPrefsLoaded) {
+ fallbackEditor.hidden = true;
+ fallbackEditor.setAttribute('aria-hidden', 'true');
+ if (fallbackState) {
+ fallbackState.textContent = 'Fallback preferences are unavailable. Retry before changing this policy.';
+ }
+ return;
+ }
+ var enabled = fallbackToggle.checked === true;
+ fallbackEditor.hidden = !enabled;
+ fallbackEditor.setAttribute('aria-hidden', enabled ? 'false' : 'true');
+ if (!fallbackState) return;
+ var candidates = fallbackWidget ? fallbackWidget.getCurrent() : [];
+ var count = candidates.length;
+ var eligibility = fallbackCatalogLoaded
+ ? summarizeForegroundFallbackCandidateEligibility(
+ candidates,
+ _foregroundFallbackEndpoints
+ )
+ : null;
+ var stateText = enabled
+ ? (
+ !count
+ ? 'No fallback candidates configured; the selected model remains strict.'
+ : eligibility && eligibility.eligible === 0 && eligibility.unknown === 0
+ ? count + (count === 1 ? ' fallback candidate is configured' : ' fallback candidates are configured')
+ + ', but none is currently eligible; the selected model remains strict.'
+ : eligibility && eligibility.eligible === 0
+ ? count + (count === 1 ? ' fallback candidate is configured' : ' fallback candidates are configured')
+ + ', but eligibility cannot be verified from the current model catalog; saved routes may still be attempted.'
+ : eligibility && (eligibility.eligible < count)
+ ? count + (count === 1 ? ' fallback candidate is configured; ' : ' fallback candidates are configured; ')
+ + eligibility.eligible + (eligibility.eligible === 1 ? ' is currently eligible' : ' are currently eligible')
+ + (eligibility.unknown
+ ? ', and ' + eligibility.unknown + ' cannot be verified.'
+ : '.')
+ : count + (count === 1 ? ' fallback candidate configured.' : ' fallback candidates configured.')
+ )
+ : 'The selected foreground model remains strict.';
+ if (!fallbackCatalogLoaded) {
+ stateText += ' Model choices are unavailable; retry to refresh the eligible endpoint list.';
+ }
+ fallbackState.textContent = stateText;
+ }
+
+ function syncFallbackRetry() {
+ if (!fallbackRetry) return;
+ var shouldHide = fallbackPrefsLoaded && fallbackCatalogLoaded;
+ if (
+ shouldHide
+ && document.activeElement === fallbackRetry
+ && fallbackToggle
+ && typeof fallbackToggle.focus === 'function'
+ ) {
+ fallbackToggle.focus();
+ }
+ fallbackRetry.hidden = shouldHide;
+ }
+
+ async function loadForegroundPolicy() {
+ try {
+ var response = await fetch('/api/prefs', { credentials: 'same-origin' });
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ return normalizeForegroundFallbackPrefs(await response.json());
+ } catch (error) {
+ console.warn('Failed to load foreground fallback preferences', error);
+ showFallbackMessage('Failed to load fallback preferences', true);
+ return null;
+ }
+ }
+
+ async function loadForegroundEndpointCatalog() {
+ try {
+ _foregroundFallbackEndpoints = await _fetchForegroundFallbackEndpoints();
+ fallbackCatalogLoaded = true;
+ if (fallbackWidget && fallbackWidget.setDisabled) fallbackWidget.setDisabled(false);
+ return true;
+ } catch (error) {
+ console.warn('Failed to load foreground fallback model choices', error);
+ fallbackCatalogLoaded = false;
+ if (fallbackWidget && fallbackWidget.setDisabled) fallbackWidget.setDisabled(true);
+ showFallbackMessage('Failed to load fallback model choices', true);
+ return false;
+ }
+ }
// Fill any with the models for a given endpoint id.
function fillModels(selectEl, epId, selected) {
@@ -458,6 +820,8 @@ async function initDefaultChat() {
_fillEndpointSelect(epSel, _endpoints, epSel.value, false);
} catch (e) { console.warn('Failed to load endpoints for default chat', e); }
+ await loadForegroundEndpointCatalog();
+
function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); }
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
@@ -466,11 +830,99 @@ async function initDefaultChat() {
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
+ if (!res.ok) throw new Error('HTTP ' + res.status);
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
} catch (e) { console.warn('Failed to load default chat settings', e); }
+ var foregroundPolicy = await loadForegroundPolicy();
+ fallbackPrefsLoaded = foregroundPolicy !== null;
+
+ if (fallbackToggle) {
+ fallbackToggle.checked = foregroundPolicy ? foregroundPolicy.enabled : false;
+ fallbackToggle.disabled = !fallbackPrefsLoaded;
+ }
+ fallbackWidget = _bindFallbackWidget({
+ containerId: 'set-foregroundFallbacks',
+ addBtnId: 'set-foregroundAddFallback',
+ endpoints: function() { return _foregroundFallbackEndpoints; },
+ initial: foregroundPolicy ? foregroundPolicy.candidates : [],
+ cleanCandidates: true,
+ requireModel: true,
+ manageAddDisabled: true,
+ allowReorder: true,
+ maxItems: MAX_FOREGROUND_FALLBACKS,
+ preserveUnavailable: true,
+ saveList: function(list) {
+ return saveForegroundPref('foreground_model_fallbacks', list);
+ },
+ onSaved: function() {
+ showFallbackMessage('Fallback order saved', false);
+ },
+ onError: function(error) {
+ console.warn('Failed to save foreground fallback list', error);
+ showFallbackMessage('Failed to save fallback order', true);
+ },
+ onReordered: function(event) {
+ showFallbackMessage(
+ 'Fallback moved to position ' + (event.to + 1) + ' of ' + event.total,
+ false
+ );
+ },
+ onRender: applyFallbackVisibility,
+ });
+ fallbackWidget.setDisabled(!fallbackCatalogLoaded);
+ applyFallbackVisibility();
+ syncFallbackRetry();
+
+ if (fallbackRetry) {
+ fallbackRetry.addEventListener('click', async function() {
+ fallbackRetry.disabled = true;
+ showFallbackMessage('Loading fallback settings…', false);
+ if (!fallbackCatalogLoaded) await loadForegroundEndpointCatalog();
+ if (!fallbackPrefsLoaded) {
+ var loadedPolicy = await loadForegroundPolicy();
+ if (loadedPolicy !== null) {
+ foregroundPolicy = loadedPolicy;
+ fallbackPrefsLoaded = true;
+ fallbackPreferenceWriter.reset();
+ fallbackWidget.setInitial(loadedPolicy.candidates);
+ if (fallbackToggle) {
+ fallbackToggle.checked = loadedPolicy.enabled;
+ fallbackToggle.disabled = false;
+ }
+ }
+ }
+ fallbackRetry.disabled = false;
+ syncFallbackRetry();
+ applyFallbackVisibility();
+ if (fallbackPrefsLoaded && fallbackCatalogLoaded) {
+ showFallbackMessage('Fallback settings loaded', false);
+ }
+ });
+ }
+
+ if (fallbackToggle) {
+ fallbackToggle.addEventListener('change', async function() {
+ if (!fallbackPrefsLoaded) return;
+ var requested = fallbackToggle.checked === true;
+ fallbackToggle.disabled = true;
+ applyFallbackVisibility();
+ try {
+ await saveForegroundPref('foreground_fallback_enabled', requested);
+ showFallbackMessage(requested ? 'Fallback enabled' : 'Strict mode saved', false);
+ } catch (error) {
+ console.warn('Failed to save foreground fallback preference', error);
+ fallbackToggle.checked = !requested;
+ applyFallbackVisibility();
+ showFallbackMessage('Failed to save fallback preference', true);
+ } finally {
+ fallbackToggle.disabled = !fallbackPrefsLoaded;
+ }
+ });
+ }
+
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
modelSel.addEventListener('change', saveDefault);
@@ -492,6 +944,29 @@ async function initDefaultChat() {
_endpoints = endpoints;
refreshEndpointOptions(epSel.value, modelSel.value);
});
+ _registerForegroundFallbackEndpointRefresh(function(endpoints, error) {
+ if (error) {
+ var fallbackList = el('set-foregroundFallbacks');
+ var focusWasInList = Boolean(
+ fallbackList
+ && document.activeElement
+ && fallbackList.contains(document.activeElement)
+ );
+ fallbackCatalogLoaded = false;
+ if (fallbackWidget && fallbackWidget.setDisabled) fallbackWidget.setDisabled(true);
+ syncFallbackRetry();
+ if (focusWasInList && fallbackRetry && typeof fallbackRetry.focus === 'function') {
+ fallbackRetry.focus();
+ }
+ applyFallbackVisibility();
+ showFallbackMessage('Failed to refresh fallback model choices', true);
+ return;
+ }
+ _foregroundFallbackEndpoints = endpoints;
+ fallbackCatalogLoaded = true;
+ if (fallbackWidget && fallbackWidget.setDisabled) fallbackWidget.setDisabled(false);
+ syncFallbackRetry();
+ });
}
/* ── Utility Model ── */
diff --git a/static/style.css b/static/style.css
index 73fdbcd5b..b1be21b62 100644
--- a/static/style.css
+++ b/static/style.css
@@ -25052,6 +25052,87 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
width: 0;
max-width: 100%;
}
+.settings-foreground-fallback-block {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: 8px;
+ margin-top: 2px;
+ border-top: 1px solid var(--border);
+}
+.settings-foreground-fallback-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--fg);
+}
+.settings-foreground-fallback-help,
+.settings-foreground-fallback-state,
+.settings-foreground-fallback-message {
+ font-size: 11px;
+ color: color-mix(in srgb, var(--fg) 55%, transparent);
+ line-height: 1.45;
+}
+.settings-foreground-fallback-state {
+ color: color-mix(in srgb, var(--fg) 70%, transparent);
+}
+.settings-foreground-fallback-editor[hidden] {
+ display: none;
+}
+.settings-foreground-fallback-order {
+ align-items: flex-start;
+}
+.settings-foreground-fallback-order > .settings-label {
+ margin-top: 6px;
+}
+.settings-foreground-fallback-list {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.settings-fallback-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.settings-fallback-remove:disabled,
+.settings-fallback-add:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ border-color: var(--border);
+ color: color-mix(in srgb, var(--fg) 45%, transparent);
+ background: transparent;
+}
+@media (max-width: 768px) {
+ .settings-foreground-fallback-toggle {
+ align-items: flex-start;
+ }
+ .settings-foreground-fallback-order {
+ display: block;
+ }
+ .settings-foreground-fallback-order > .settings-label {
+ display: block;
+ margin: 0 0 6px;
+ }
+ .settings-foreground-fallback-block .settings-fallback-row {
+ display: grid;
+ grid-template-columns: 14px minmax(0, 1fr) minmax(0, 1fr);
+ padding-left: 6px;
+ }
+ .settings-foreground-fallback-block .settings-fallback-row .settings-select {
+ width: 100%;
+ max-width: none;
+ }
+ .settings-foreground-fallback-block .settings-fallback-actions {
+ grid-column: 2 / 4;
+ justify-self: end;
+ }
+}
/* Cookbook Serve Advanced fold — wraps the rarely-touched tuning rows
(KV/Attention/Swap/Env for vLLM, llama.cpp batch/cache/split, VRAM
monitor, speculative, extra args). Matches the existing .hwfit-panel-
@@ -25245,7 +25326,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
cursor: pointer;
transition: border-color 0.12s, color 0.12s, background 0.12s;
}
-.settings-fallback-remove:hover {
+.settings-fallback-remove:not(:disabled):hover {
border-color: var(--red);
color: var(--red);
background: color-mix(in srgb, var(--red) 10%, transparent);
@@ -25262,7 +25343,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
-.settings-fallback-add:hover {
+.settings-fallback-add:not(:disabled):hover {
border-color: var(--red);
color: var(--red);
}
diff --git a/static/sw.js b/static/sw.js
index dea990b71..f8b082989 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
-const CACHE_NAME = 'odysseus-v376-settings-title-icons';
+const CACHE_NAME = 'odysseus-v345';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the