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…
+ +
+ +
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