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.
This commit is contained in:
RaresKeY 2026-07-21 20:31:28 +00:00
parent 64fdd2f822
commit a47d9c5450
11 changed files with 1858 additions and 29 deletions

View file

@ -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,

View file

@ -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

View file

@ -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:

View file

@ -1482,6 +1482,28 @@
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
<select id="set-defaultModelSelect" class="settings-select"></select>
</div>
<div class="settings-foreground-fallback-block">
<div class="settings-foreground-fallback-toggle">
<span>Allow fallback when the selected model is unavailable</span>
<label class="admin-switch" title="Allow availability-only foreground model fallback">
<input type="checkbox" id="set-foregroundFallbackToggle" aria-label="Allow fallback when the selected model is unavailable" disabled>
<span class="admin-slider"></span>
</label>
</div>
<div class="admin-toggle-sub settings-foreground-fallback-help">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.</div>
<div id="set-foregroundFallbackState" class="settings-foreground-fallback-state" aria-live="polite">Loading fallback settings…</div>
<div id="set-foregroundFallbackEditor" class="settings-foreground-fallback-editor" hidden aria-hidden="true">
<div class="settings-row settings-foreground-fallback-order">
<label class="settings-label">Fallback order</label>
<div class="settings-foreground-fallback-list">
<div id="set-foregroundFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-foregroundAddFallback" title="Add a concrete endpoint and model to the fallback order">+ Add fallback</button>
</div>
</div>
</div>
<div id="set-foregroundFallbackMsg" class="settings-foreground-fallback-message" aria-live="polite"></div>
<button type="button" class="settings-fallback-add settings-foreground-fallback-retry" id="set-foregroundFallbackRetry" hidden>Retry loading fallback settings</button>
</div>
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>

View file

@ -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);
}

View file

@ -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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
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: '<path d="M6 15l6-6 6 6"/>',
},
{
offset: 1,
title: 'Move fallback down',
path: '<path d="M6 9l6 6 6-6"/>',
},
].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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + action.path + '</svg>';
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 <select> 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 ── */

View file

@ -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);
}

View file

@ -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 <script type="module"> tags
@ -44,6 +44,7 @@ const PRECACHE = [
'/static/js/theme.js',
'/static/js/censor.js',
'/static/js/settings.js',
'/static/js/foregroundFallbackSettings.js',
'/static/js/admin.js',
'/static/js/init.js',
'/static/js/slashCommands.js',

View file

@ -0,0 +1,461 @@
"""Per-user Settings controls for explicit foreground model fallback."""
import json
from pathlib import Path
import shutil
import subprocess
from bs4 import BeautifulSoup
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
import routes.prefs_routes as prefs_routes
import src.foreground_model_routing as foreground_model_routing
_REPO = Path(__file__).resolve().parents[1]
_SETTINGS_SOURCE = (_REPO / "static" / "js" / "settings.js").read_text(
encoding="utf-8"
)
_STATE_MODULE = (
_REPO / "static" / "js" / "foregroundFallbackSettings.js"
).as_uri()
def test_default_chat_card_exposes_strict_off_fallback_controls():
soup = BeautifulSoup(
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
"html.parser",
)
toggle = soup.find(id="set-foregroundFallbackToggle")
editor = soup.find(id="set-foregroundFallbackEditor")
assert toggle is not None
assert toggle.name == "input"
assert toggle.get("type") == "checkbox"
assert toggle.get("aria-label") == (
"Allow fallback when the selected model is unavailable"
)
assert not toggle.has_attr("checked")
assert toggle.has_attr("disabled")
assert editor is not None
assert editor.has_attr("hidden")
assert soup.find(id="set-foregroundFallbackState").get("aria-live") == "polite"
assert soup.find(id="set-foregroundFallbacks") is not None
assert soup.find(id="set-foregroundAddFallback") is not None
retry = soup.find(id="set-foregroundFallbackRetry")
assert retry is not None
assert retry.has_attr("hidden")
text = soup.get_text(" ", strip=True)
assert "Allow fallback when the selected model is unavailable" in text
assert "Loading fallback settings…" in text
assert "Authentication, authorization, request, unsupported-model" in text
assert "No fallback candidates configured" not in text
def test_default_chat_controls_use_only_owner_scoped_new_preferences():
start = _SETTINGS_SOURCE.index("async function initDefaultChat()")
end = _SETTINGS_SOURCE.index("/* ── Utility Model ── */", start)
source = _SETTINGS_SOURCE[start:end]
assert "fetch('/api/prefs'" in source
assert "/api/models?background=false&foreground_fallback=true" in _SETTINGS_SOURCE
assert "endpoints: function() { return _foregroundFallbackEndpoints; }" in source
assert "saveForegroundPref('foreground_fallback_enabled'" in source
assert "saveForegroundPref('foreground_model_fallbacks'" in source
assert "allowReorder: true" in source
assert "preserveUnavailable: true" in source
assert "maxItems: MAX_FOREGROUND_FALLBACKS" in source
assert "fallbackPrefsLoaded = foregroundPolicy !== null" in source
assert "fallbackToggle.disabled = !fallbackPrefsLoaded" in source
assert "if (fallbackToggle) fallbackToggle.disabled = true" in source
assert "Retry before changing this policy" in source
assert "return null;" in source
assert "fallbackRetry.addEventListener('click'" in source
assert "document.activeElement === fallbackRetry" in source
assert "_registerForegroundFallbackEndpointRefresh(function(endpoints, error)" in source
assert "fallbackCatalogLoaded = false" in source
assert "Failed to refresh fallback model choices" in source
assert "fallbackWidget.setDisabled(!fallbackCatalogLoaded)" in source
assert "fallbackWidget.setDisabled(true)" in source
assert "fallbackWidget.setDisabled(false)" in source
assert "focusWasInList" in source
assert "fallbackWidget.setInitial(loadedPolicy.candidates)" in source
assert "fallbackPreferenceWriter.reset();" in source
assert "createForegroundPreferenceSaveQueue(" in source
assert "fallbackPrefsLoaded = false;" in source
assert "function selectableEps()" in _SETTINGS_SOURCE
assert "function firstUnusedCandidate()" in _SETTINGS_SOURCE
assert "nextForegroundFallbackCandidate(" in _SETTINGS_SOURCE
assert "summarizeForegroundFallbackCandidateEligibility(" in _SETTINGS_SOURCE
assert "none is currently eligible; the selected model remains strict" in source
assert "eligibility cannot be verified from the current model catalog" in source
assert "captureFallbackWidgetFocus(fbContainer, document.activeElement)" in _SETTINGS_SOURCE
assert "restoreFallbackWidgetFocus(fbContainer, addBtn, focusState)" in _SETTINGS_SOURCE
assert "'Fallback ' + (idx + 1) + ' endpoint'" in _SETTINGS_SOURCE
assert "'Fallback ' + (idx + 1) + ' model'" in _SETTINGS_SOURCE
assert "'Remove fallback ' + (idx + 1)" in _SETTINGS_SOURCE
assert "' to position ' + (destination + 1)" in _SETTINGS_SOURCE
assert "move.dataset.moveOffset = String(action.offset)" in _SETTINGS_SOURCE
assert "if (focusTarget) focusTarget.focus()" in _SETTINGS_SOURCE
assert "onReordered: function(event)" in source
assert "default_model_fallbacks" not in source
assert "/api/auth/settings" in source # Existing default endpoint/model only.
def test_service_worker_precaches_new_module_and_bumps_cache():
source = (_REPO / "static" / "sw.js").read_text(encoding="utf-8")
assert "const CACHE_NAME = 'odysseus-v345';" in source
assert "'/static/js/foregroundFallbackSettings.js'" in source
def test_foreground_fallback_editor_has_mobile_layout_and_disabled_states():
source = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
assert ".settings-foreground-fallback-editor[hidden]" in source
assert ".settings-fallback-remove:disabled" in source
assert ".settings-fallback-remove:not(:disabled):hover" in source
assert ".settings-fallback-add:not(:disabled):hover" in source
assert "@media (max-width: 768px)" in source
assert ".settings-foreground-fallback-block .settings-fallback-row" in source
assert "grid-template-columns: 14px minmax(0, 1fr) minmax(0, 1fr);" in source
@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
def test_foreground_fallback_state_ignores_legacy_and_reorders_exact_candidates():
script = f"""
import {{
captureFallbackWidgetFocus,
countEligibleForegroundFallbackCandidates,
createForegroundPreferenceSaveQueue,
moveForegroundFallbackCandidate,
nextForegroundFallbackCandidate,
normalizeForegroundFallbackModelCatalog,
normalizeForegroundFallbackPrefs,
restoreFallbackWidgetFocus,
summarizeForegroundFallbackCandidateEligibility,
}} from {json.dumps(_STATE_MODULE)};
const legacyOnly = normalizeForegroundFallbackPrefs({{
default_model_fallbacks: [{{endpoint_id: 'legacy', model: 'legacy-model'}}],
}});
const configured = normalizeForegroundFallbackPrefs({{
foreground_fallback_enabled: true,
foreground_model_fallbacks: [
{{endpoint_id: 'one', model: 'model-one'}},
{{endpoint_id: 'two', model: 'model-two'}},
{{endpoint_id: 'one', model: 'model-one'}},
{{endpoint_id: '', model: 'invalid'}},
null,
],
default_model_fallbacks: [{{endpoint_id: 'legacy', model: 'ignored'}}],
}});
const moved = moveForegroundFallbackCandidate(configured.candidates, 1, -1);
const boundary = moveForegroundFallbackCandidate(moved, 0, -1);
const oversized = normalizeForegroundFallbackPrefs({{
foreground_fallback_enabled: true,
foreground_model_fallbacks: Array.from({{length: 12}}, (_, index) => ({{
endpoint_id: `endpoint-${{index}}`,
model: `model-${{index}}`,
}})),
}});
const catalog = normalizeForegroundFallbackModelCatalog({{items: [
{{
endpoint_id: 'chat',
endpoint_name: 'Chat endpoint',
model_type: 'llm',
models: ['primary', 'duplicate'],
models_extra: ['duplicate', 'extra'],
}},
{{
endpoint_id: 'image',
endpoint_name: 'Image endpoint',
model_type: 'image',
models: ['image-model'],
}},
{{endpoint_id: '', endpoint_name: 'Invalid', models: ['ignored']}},
]}});
const repeatedAdds = [];
for (let index = 0; index < 4; index += 1) {{
const next = nextForegroundFallbackCandidate(catalog, repeatedAdds);
if (next) repeatedAdds.push(next);
}}
const reloadedAdds = normalizeForegroundFallbackPrefs({{
foreground_fallback_enabled: true,
foreground_model_fallbacks: repeatedAdds.concat(repeatedAdds[0]),
}});
const effectiveCount = countEligibleForegroundFallbackCandidates(
repeatedAdds.concat({{endpoint_id: 'stale', model: 'missing'}}),
catalog
);
const staleOnlyCount = countEligibleForegroundFallbackCandidates(
[{{endpoint_id: 'stale', model: 'missing'}}],
catalog
);
const unknownEligibility = summarizeForegroundFallbackCandidateEligibility(
[{{endpoint_id: 'offline', model: 'saved-model'}}],
[{{
id: 'offline',
models: [],
is_enabled: true,
model_catalog_unknown: true,
allowed_unknown_models: null,
}}]
);
const restrictedUnknownEligibility = summarizeForegroundFallbackCandidateEligibility(
[{{endpoint_id: 'offline', model: 'blocked-model'}}],
[{{
id: 'offline',
models: [],
is_enabled: true,
model_catalog_unknown: true,
allowed_unknown_models: ['allowed-model'],
}}]
);
const knownStrictEligibility = summarizeForegroundFallbackCandidateEligibility(
[{{endpoint_id: 'disabled', model: 'saved-model'}}],
catalog
);
const preferenceWrites = [];
let preferenceUnavailableCount = 0;
let rejectNextPreferenceWrite = true;
const preferenceWriter = createForegroundPreferenceSaveQueue(
async (key, value) => {{
preferenceWrites.push([key, value]);
if (rejectNextPreferenceWrite) {{
rejectNextPreferenceWrite = false;
throw new Error('write failed');
}}
}},
() => {{ preferenceUnavailableCount += 1; }}
);
const failedListWrite = preferenceWriter.save(
'foreground_model_fallbacks',
[{{endpoint_id: 'chat', model: 'primary'}}]
);
const blockedEnableWrite = preferenceWriter.save(
'foreground_fallback_enabled',
true
);
const failedWriteResults = await Promise.allSettled([
failedListWrite,
blockedEnableWrite,
]);
preferenceWriter.reset();
await preferenceWriter.save('foreground_fallback_enabled', true);
const focusLog = [];
function control(key) {{
return {{
dataset: {{fallbackFocus: key}},
focus() {{ focusLog.push(key); }},
}};
}}
function row(controls) {{
return {{
controls,
querySelectorAll() {{ return this.controls; }},
}};
}}
const oldModel = control('model');
const oldRemove = control('remove');
const firstRow = row([oldModel, oldRemove]);
oldModel.closest = () => firstRow;
oldRemove.closest = () => firstRow;
const focusContainer = {{
children: [firstRow],
contains(active) {{ return firstRow.controls.includes(active); }},
}};
const modelFocus = captureFallbackWidgetFocus(focusContainer, oldModel);
focusContainer.children = [row([control('model'), control('remove')])];
restoreFallbackWidgetFocus(focusContainer, control('add'), modelFocus);
const removeFocus = captureFallbackWidgetFocus(
{{children: [firstRow], contains(active) {{ return active === oldRemove; }}}},
oldRemove
);
focusContainer.children = [];
restoreFallbackWidgetFocus(focusContainer, control('add'), removeFocus);
console.log(JSON.stringify({{
legacyOnly,
configured,
moved,
boundary,
oversized,
catalog,
repeatedAdds,
reloadedAdds,
effectiveCount,
staleOnlyCount,
unknownEligibility,
restrictedUnknownEligibility,
knownStrictEligibility,
preferenceWrites,
preferenceUnavailableCount,
failedWriteStatuses: failedWriteResults.map(result => result.status),
focusLog,
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"legacyOnly": {"enabled": False, "candidates": []},
"configured": {
"enabled": True,
"candidates": [
{"endpoint_id": "one", "model": "model-one"},
{"endpoint_id": "two", "model": "model-two"},
],
},
"moved": [
{"endpoint_id": "two", "model": "model-two"},
{"endpoint_id": "one", "model": "model-one"},
],
"boundary": [
{"endpoint_id": "two", "model": "model-two"},
{"endpoint_id": "one", "model": "model-one"},
],
"oversized": {
"enabled": True,
"candidates": [
{"endpoint_id": f"endpoint-{index}", "model": f"model-{index}"}
for index in range(10)
],
},
"catalog": [
{
"id": "chat",
"name": "Chat endpoint",
"is_enabled": True,
"models": ["primary", "duplicate", "extra"],
"online": True,
"model_catalog_unknown": False,
"allowed_unknown_models": None,
},
],
"repeatedAdds": [
{"endpoint_id": "chat", "model": "primary"},
{"endpoint_id": "chat", "model": "duplicate"},
{"endpoint_id": "chat", "model": "extra"},
],
"reloadedAdds": {
"enabled": True,
"candidates": [
{"endpoint_id": "chat", "model": "primary"},
{"endpoint_id": "chat", "model": "duplicate"},
{"endpoint_id": "chat", "model": "extra"},
],
},
"effectiveCount": 3,
"staleOnlyCount": 0,
"unknownEligibility": {
"configured": 1,
"eligible": 0,
"unknown": 1,
"ineligible": 0,
},
"restrictedUnknownEligibility": {
"configured": 1,
"eligible": 0,
"unknown": 0,
"ineligible": 1,
},
"knownStrictEligibility": {
"configured": 1,
"eligible": 0,
"unknown": 0,
"ineligible": 1,
},
"preferenceWrites": [
[
"foreground_model_fallbacks",
[{"endpoint_id": "chat", "model": "primary"}],
],
["foreground_fallback_enabled", True],
],
"preferenceUnavailableCount": 1,
"failedWriteStatuses": ["rejected", "rejected"],
"focusLog": ["model", "add"],
}
def test_preferences_api_roundtrips_fallback_policy_per_user_without_legacy_copy(
tmp_path,
monkeypatch,
):
prefs_file = tmp_path / "user_prefs.json"
legacy = [{"endpoint_id": "legacy", "model": "legacy-model"}]
prefs_file.write_text(
json.dumps({"default_model_fallbacks": legacy}),
encoding="utf-8",
)
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
current = {"owner": "alice"}
monkeypatch.setattr(
prefs_routes,
"get_current_user",
lambda request: current["owner"],
)
app = FastAPI()
app.include_router(prefs_routes.setup_prefs_routes())
client = TestClient(app)
candidates = [
{"endpoint_id": "backup-two", "model": "model-two"},
{"endpoint_id": "backup-one", "model": "model-one"},
]
assert client.put(
"/api/prefs/foreground_fallback_enabled",
json={"value": True},
).status_code == 200
assert client.put(
"/api/prefs/foreground_model_fallbacks",
json={"value": candidates},
).status_code == 200
alice = client.get("/api/prefs").json()
assert alice == {
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": candidates,
}
monkeypatch.setattr(
foreground_model_routing,
"resolve_fallback_entries",
lambda entries, owner=None, require_exact_model=False: [
("https://backup-two.example/v1", entries[0]["model"], {}),
("https://backup-one.example/v1", entries[1]["model"], {}),
],
)
policy = foreground_model_routing.resolve_foreground_model_policy("alice")
assert policy.enabled is True
assert [candidate[1] for candidate in policy.fallback_candidates] == [
"model-two",
"model-one",
]
current["owner"] = "bob"
assert client.get("/api/prefs").json() == {}
assert client.put(
"/api/prefs/foreground_fallback_enabled",
json={"value": False},
).status_code == 200
assert client.get("/api/prefs").json() == {
"foreground_fallback_enabled": False,
}
current["owner"] = "alice"
assert client.get("/api/prefs").json()["foreground_model_fallbacks"] == candidates
raw = json.loads(prefs_file.read_text(encoding="utf-8"))
assert raw["default_model_fallbacks"] == legacy
assert "default_model_fallbacks" not in raw["_users"]["alice"]
assert "default_model_fallbacks" not in raw["_users"]["bob"]

View file

@ -1708,7 +1708,333 @@ def test_api_models_scopes_api_token_to_token_owner(monkeypatch):
assert admin_checks == ["alice"]
def test_api_models_returns_only_pinned_proxy_models_without_refresh_probe(monkeypatch):
def test_foreground_fallback_catalog_is_owner_scoped_and_allowlist_filtered(
monkeypatch,
):
image = _route_ep(
"alice-image",
"http://alice-image.example/v1",
cached_models=["image-model"],
owner="alice",
)
image.model_type = "image"
rows = [
_route_ep(
"alice",
"http://alice.example/v1",
cached_models=["allowed-model", "blocked-model"],
owner="alice",
),
_route_ep(
"shared",
"http://shared.example/v1",
cached_models=["shared-model"],
owner=None,
),
_route_ep(
"bob",
"http://bob.example/v1",
cached_models=["allowed-model"],
owner="bob",
),
image,
]
db = _RouteDb(rows)
router = model_routes.setup_model_routes(model_discovery=None)
privileges = {
"allowed_models_restricted": True,
"allowed_models": ["allowed-model", "shared-model"],
}
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(threading, "Thread", _NoopThread)
request = SimpleNamespace(
state=SimpleNamespace(current_user="alice"),
app=SimpleNamespace(
state=SimpleNamespace(
auth_manager=SimpleNamespace(
is_configured=True,
is_admin=lambda user: False,
get_privileges=lambda user: privileges,
),
),
),
)
result = _route_endpoint(router, "/api/models")(
request,
foreground_fallback=True,
)
assert [item["endpoint_name"] for item in result["items"]] == [
"alice",
"shared",
]
assert result["items"][0]["models"] == ["allowed-model"]
assert result["items"][1]["models"] == ["shared-model"]
assert result["items"][0]["model_catalog_unknown"] is False
assert all("api_key" not in item for item in result["items"])
privileges.clear()
privileges["block_all_models"] = True
blocked = _route_endpoint(router, "/api/models")(
request,
foreground_fallback=True,
)
assert blocked["items"] == []
def test_foreground_fallback_catalog_distinguishes_unknown_and_hidden_models(
monkeypatch,
):
unknown = _route_ep(
"unknown",
"http://unknown.example/v1",
cached_models=[],
owner="alice",
)
hidden_only = _route_ep(
"hidden-only",
"http://hidden-only.example/v1",
cached_models=[],
owner="alice",
)
hidden_only.hidden_models = json.dumps(["blocked-model"])
hidden = _route_ep(
"hidden",
"http://hidden.example/v1",
cached_models=["hidden-model"],
owner="alice",
)
hidden.hidden_models = json.dumps(["hidden-model"])
db = _RouteDb([unknown, hidden_only, hidden])
router = model_routes.setup_model_routes(model_discovery=None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(threading, "Thread", _NoopThread)
request = SimpleNamespace(
state=SimpleNamespace(current_user="alice"),
app=SimpleNamespace(
state=SimpleNamespace(
auth_manager=SimpleNamespace(
is_configured=True,
is_admin=lambda user: False,
get_privileges=lambda user: {
"allowed_models_restricted": True,
"allowed_models": ["allowed-model"],
},
),
),
),
)
result = _route_endpoint(router, "/api/models")(
request,
foreground_fallback=True,
)
by_name = {item["endpoint_name"]: item for item in result["items"]}
assert by_name["unknown"]["model_catalog_unknown"] is True
assert by_name["unknown"]["allowed_unknown_models"] == ["allowed-model"]
assert by_name["hidden-only"]["model_catalog_unknown"] is False
assert by_name["hidden-only"]["allowed_unknown_models"] is None
assert by_name["hidden"]["model_catalog_unknown"] is False
assert by_name["hidden"]["allowed_unknown_models"] is None
ordinary = _route_endpoint(router, "/api/models")(request)
assert all(
"_foreground_model_catalog_unknown" not in item
for item in ordinary["items"]
)
def test_foreground_fallback_catalog_keeps_admin_on_exact_owner_boundary(
monkeypatch,
):
rows = [
_route_ep(
"admin",
"http://admin.example/v1",
cached_models=["admin-model"],
owner="admin",
),
_route_ep(
"shared",
"http://shared.example/v1",
cached_models=["shared-model"],
owner=None,
),
_route_ep(
"alice",
"http://alice.example/v1",
cached_models=["alice-model"],
owner="alice",
),
]
db = _RouteDb(rows)
router = model_routes.setup_model_routes(model_discovery=None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(threading, "Thread", _NoopThread)
request = SimpleNamespace(
state=SimpleNamespace(current_user="admin"),
app=SimpleNamespace(
state=SimpleNamespace(
auth_manager=SimpleNamespace(
is_configured=True,
is_admin=lambda user: True,
get_privileges=lambda user: {},
),
),
),
)
ordinary = _route_endpoint(router, "/api/models")(request)
foreground = _route_endpoint(router, "/api/models")(
request,
foreground_fallback=True,
)
assert [item["endpoint_name"] for item in ordinary["items"]] == [
"admin",
"shared",
"alice",
]
assert [item["endpoint_name"] for item in foreground["items"]] == [
"admin",
"shared",
]
def test_foreground_fallback_catalog_omits_runtime_unresolvable_credentials(
monkeypatch,
):
static = _route_ep(
"static",
"http://static.example/v1",
cached_models=["static-model"],
owner="alice",
)
expired = _route_ep(
"expired",
"http://subscription.example/v1",
cached_models=["subscription-model"],
owner="alice",
)
expired.provider_auth_id = "expired-auth"
db = _RouteDb([static, expired])
router = model_routes.setup_model_routes(model_discovery=None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(threading, "Thread", _NoopThread)
monkeypatch.setattr(
model_routes,
"resolve_endpoint_runtime",
lambda endpoint, owner=None: (
(_ for _ in ()).throw(RuntimeError("expired"))
if endpoint.id == "expired"
else (endpoint.base_url, endpoint.api_key)
),
)
request = SimpleNamespace(
state=SimpleNamespace(current_user="alice"),
app=SimpleNamespace(
state=SimpleNamespace(
auth_manager=SimpleNamespace(
is_configured=True,
is_admin=lambda user: False,
get_privileges=lambda user: {},
),
),
),
)
ordinary = _route_endpoint(router, "/api/models")(request)
foreground = _route_endpoint(router, "/api/models")(
request,
foreground_fallback=True,
)
assert [item["endpoint_name"] for item in ordinary["items"]] == [
"static",
"expired",
]
assert [item["endpoint_name"] for item in foreground["items"]] == [
"static",
]
def test_foreground_fallback_catalog_applies_allowlist_before_credentials(
monkeypatch,
):
rows = [
_route_ep(
"allowed",
"http://allowed.example/v1",
cached_models=["allowed-model"],
owner="alice",
),
_route_ep(
"blocked",
"http://blocked.example/v1",
cached_models=["blocked-model"],
owner="alice",
),
]
db = _RouteDb(rows)
router = model_routes.setup_model_routes(model_discovery=None)
resolved = []
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(threading, "Thread", _NoopThread)
def resolve_runtime(endpoint, owner=None):
resolved.append((endpoint.id, owner))
if endpoint.id == "blocked":
raise AssertionError("blocked model credentials must not resolve")
return endpoint.base_url, endpoint.api_key
monkeypatch.setattr(model_routes, "resolve_endpoint_runtime", resolve_runtime)
request = SimpleNamespace(
state=SimpleNamespace(current_user="alice"),
app=SimpleNamespace(
state=SimpleNamespace(
auth_manager=SimpleNamespace(
is_configured=True,
is_admin=lambda user: False,
get_privileges=lambda user: {
"allowed_models_restricted": True,
"allowed_models": ["allowed-model"],
},
),
),
),
)
result = _route_endpoint(router, "/api/models")(
request,
foreground_fallback=True,
)
assert resolved == [("allowed", "alice")]
assert {
item["endpoint_name"]: item["models"] for item in result["items"]
} == {
"allowed": ["allowed-model"],
"blocked": [],
}
def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch):
row = _route_ep(
"proxy",
"http://100.117.136.97:34521/v1",

View file

@ -55,7 +55,7 @@ class _FakeDb:
pass
def _endpoint(ep_id, model, *, hidden=None):
def _endpoint(ep_id, model, *, hidden=None, model_type="llm"):
return SimpleNamespace(
id=ep_id,
name=f"Endpoint {ep_id}",
@ -63,6 +63,7 @@ def _endpoint(ep_id, model, *, hidden=None):
api_key=f"key-{ep_id}",
cached_models=json.dumps([model]),
hidden_models=json.dumps(hidden or []),
model_type=model_type,
is_enabled=True,
)
@ -232,6 +233,51 @@ def test_exact_fallback_drops_known_missing_model(monkeypatch):
) is None
def test_exact_fallback_treats_hidden_only_inventory_as_known(monkeypatch):
endpoint = SimpleNamespace(
id="fallback",
base_url="https://fallback.example/v1",
api_key="key-fallback",
cached_models=json.dumps([]),
pinned_models=json.dumps([]),
hidden_models=json.dumps(["blocked-model"]),
is_enabled=True,
)
_install_resolver_fakes(monkeypatch, {}, [endpoint])
assert resolve_endpoint_by_id(
"fallback",
"blocked-model",
require_exact_model=True,
) is None
assert resolve_endpoint_by_id(
"fallback",
"another-model",
require_exact_model=True,
) is None
def test_exact_fallback_rejects_unlisted_model_when_all_known_models_hidden(
monkeypatch,
):
endpoint = SimpleNamespace(
id="fallback",
base_url="https://fallback.example/v1",
api_key="key-fallback",
cached_models=json.dumps(["hidden-model"]),
pinned_models=json.dumps([]),
hidden_models=json.dumps(["hidden-model"]),
is_enabled=True,
)
_install_resolver_fakes(monkeypatch, {}, [endpoint])
assert resolve_endpoint_by_id(
"fallback",
"other-model",
require_exact_model=True,
) is None
def test_fallback_entry_resolution_preserves_credential_distinct_endpoints(monkeypatch):
seen = []
@ -285,6 +331,25 @@ def test_descriptor_resolution_preserves_safe_endpoint_identity(monkeypatch):
)]
def test_foreground_descriptor_resolution_rejects_non_llm_endpoint(monkeypatch):
_install_resolver_fakes(
monkeypatch,
{},
[_endpoint("image", "image-model", model_type="image")],
)
assert resolve_fallback_entries_with_descriptors(
[{"endpoint_id": "image", "model": "image-model"}],
require_exact_model=True,
required_model_type="llm",
) == []
assert resolve_endpoint_by_id(
"image",
"image-model",
require_exact_model=True,
) is not None
def test_endpoint_cost_tracking_is_non_secret_route_classification():
assert endpoint_cost_tracked("http://localhost:11434/v1") is False
assert endpoint_cost_tracked("http://model-service:8000/v1") is False