mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
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:
parent
64fdd2f822
commit
a47d9c5450
11 changed files with 1858 additions and 29 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
210
static/js/foregroundFallbackSettings.js
Normal file
210
static/js/foregroundFallbackSettings.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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 ── */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
461
tests/test_foreground_fallback_settings_ui.py
Normal file
461
tests/test_foreground_fallback_settings_ui.py
Normal 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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue