From a47d9c545047aa057331249a470273c49f4c2fde Mon Sep 17 00:00:00 2001
From: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Date: Tue, 21 Jul 2026 20:31:28 +0000
Subject: [PATCH] feat(settings): add foreground fallback controls
Add an off-by-default per-user foreground fallback toggle and an ordered editor for unique concrete LLM endpoint/model candidates. Keep the catalog and runtime on the same owner, allowlist, endpoint-type, known/unknown inventory, and runtime-credential boundaries; preserve legacy data and stale rows without misleading strict-state claims; and fail closed across loading, refresh, and preference-write errors. Reuse existing Settings controls while preserving save ordering, keyboard focus, responsive layout, list limits, and service-worker caching.
---
routes/model_routes.py | 173 +++++-
src/endpoint_resolver.py | 28 +-
src/foreground_model_routing.py | 1 +
static/index.html | 22 +
static/js/foregroundFallbackSettings.js | 210 ++++++++
static/js/settings.js | 509 +++++++++++++++++-
static/style.css | 85 ++-
static/sw.js | 3 +-
tests/test_foreground_fallback_settings_ui.py | 461 ++++++++++++++++
tests/test_model_routes.py | 328 ++++++++++-
tests/test_resolve_endpoint_fallbacks.py | 67 ++-
11 files changed, 1858 insertions(+), 29 deletions(-)
create mode 100644 static/js/foregroundFallbackSettings.js
create mode 100644 tests/test_foreground_fallback_settings_ui.py
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.