diff --git a/routes/model_routes.py b/routes/model_routes.py index 82ec7261a..398f4d703 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -9,6 +9,7 @@ import ipaddress import socket import time as _time import logging +import threading import httpx from datetime import datetime from typing import List, Dict, Any, Optional @@ -17,6 +18,9 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon from pydantic import BaseModel from fastapi.responses import StreamingResponse from core.database import SessionLocal, ModelEndpoint, Session as DbSession + +_featherless_search_cache: Dict[tuple, tuple[float, Dict[str, Any]]] = {} +_featherless_search_cache_lock = threading.Lock() try: from core.log_safety import redact_url as _redact_url_for_log except ModuleNotFoundError: @@ -854,6 +858,8 @@ def _effective_endpoint_kind(ep: Any, base_url: str) -> str: kind = _endpoint_kind(ep) if kind != "auto": return kind + if _host_match(base_url, "featherless.ai"): + return "api" if getattr(ep, "api_key", None) and not _is_ollama_base(base_url): try: path = (urlparse(base_url).path or "").rstrip("/") @@ -1015,6 +1021,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis if api_key: return fetch_available_models(api_key, timeout=timeout) return [] + if provider == "featherless" or _host_match(base, "featherless.ai"): + return [] if _is_google_api_base(base): try: models = _probe_google_models(base, api_key, timeout=timeout) @@ -1165,6 +1173,31 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> last_error: Optional[str] = None + if _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless": + plan_base = base if base.endswith("/v1") else f"{base}/v1" + plan_url = f"{plan_base}/plan" + try: + r = httpx.get(plan_url, headers=headers, timeout=timeout, verify=llm_verify()) + result = _result_from_response(r) + if result["reachable"]: + return result + if r.status_code in (401, 403): + return {"reachable": False, "status_code": r.status_code, "error": "Featherless API key invalid or unauthorized"} + except Exception as e: + last_error = str(e)[:120] + + try: + models_url = f"{plan_base}/models?available_on_current_plan=true&status=active&conversational=true&page=1&per_page=1" + r = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify()) + result = _result_from_response(r) + if result["reachable"]: + return result + if r.status_code in (401, 403): + return {"reachable": False, "status_code": r.status_code, "error": "Featherless API key invalid or unauthorized"} + return result + except Exception as e: + return {"reachable": False, "status_code": None, "error": str(e)[:120]} + try: if looks_like_ollama: root = base @@ -1517,6 +1550,8 @@ def setup_model_routes(model_discovery): } if not base: return False, info + if _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless": + return False, info if state.get("inflight"): return False, info if mode in ("manual", "disabled") and not force: @@ -2046,9 +2081,10 @@ def setup_model_routes(model_discovery): if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r): r.pinned_models = json.dumps(pinned) upgraded_legacy_pins = True - model_inventory_count = len(_merge_model_ids(all_models, pinned)) + is_featherless = _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless" + model_inventory_count = len(pinned) if is_featherless else len(_merge_model_ids(all_models, pinned)) picker_requires_pinning = _picker_requires_pinning(base, kind) - status = "online" if (all_models or visible or pinned) else ("empty" if r.is_enabled else "offline") + status = "online" if (all_models or visible or pinned or (is_featherless and r.is_enabled)) else ("empty" if r.is_enabled else "offline") results.append({ "id": r.id, "name": r.name, @@ -2114,11 +2150,19 @@ def setup_model_routes(model_discovery): # keep those container-local when the frontend marks them as such. base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local)) + is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" # Auto-generate name from URL if not provided if not name.strip(): - name = base_url.replace("http://", "").replace("https://", "").split("/")[0] + if is_featherless: + name = "Featherless.ai" + else: + name = base_url.replace("http://", "").replace("https://", "").split("/")[0] requested_kind = _normalize_endpoint_kind(endpoint_kind) + if is_featherless and requested_kind == "auto": + requested_kind = "api" + if is_featherless and not pinned_models.strip(): + pinned_models = "[]" refresh_mode = _normalize_endpoint_refresh_mode(model_refresh_mode, requested_kind, base_url) refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400) refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60) @@ -2212,6 +2256,8 @@ def setup_model_routes(model_discovery): existing_models = _cached_model_ids(existing) _existing_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None)) existing_kind = _effective_endpoint_kind(existing, existing.base_url) + is_existing_featherless = _host_match(existing.base_url, "featherless.ai") or _safe_detect_provider(existing.base_url) == "featherless" + existing_status = "online" if (existing.is_enabled and is_existing_featherless) else ("online" if (existing_models or _existing_pinned) else ("empty" if existing.is_enabled else "offline")) return { "id": existing.id, "name": existing.name, @@ -2224,8 +2270,8 @@ def setup_model_routes(model_discovery): existing.pinned_models, ), "pinned_models": _existing_pinned, - "online": True, - "status": "online", + "online": existing_status != "offline", + "status": existing_status, "existing": True, "endpoint_kind": existing_kind, "category": _classify_endpoint(existing.base_url, existing_kind), @@ -2237,7 +2283,7 @@ def setup_model_routes(model_discovery): ping = {"reachable": False, "error": None} if (should_probe or requested_kind in ("api", "proxy")) and not model_ids: ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 10.0)) - if require_model_list and not model_ids: + if require_model_list and not model_ids and not is_featherless: raise HTTPException(400, _model_endpoint_error_message(base_url, ping)) ep_id = str(uuid.uuid4())[:8] @@ -2267,8 +2313,8 @@ def setup_model_routes(model_discovery): model_refresh_mode=refresh_mode, model_refresh_interval=refresh_interval, model_refresh_timeout=refresh_timeout, - cached_models=json.dumps(model_ids) if model_ids else None, - pinned_models=json.dumps(_pinned) if _pinned else None, + cached_models=None if is_featherless else (json.dumps(model_ids) if model_ids else None), + pinned_models=json.dumps(_pinned) if (is_featherless or _pinned) else None, supports_tools=_st, owner=_owner_val, ) @@ -2308,6 +2354,8 @@ def setup_model_routes(model_discovery): db.close() # Return immediately — probing happens via the separate /probe SSE endpoint + is_online = bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")) or (is_featherless and ping.get("reachable")) + is_status = "online" if (model_ids or _pinned or (is_featherless and ping.get("reachable"))) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")) return { "id": ep_id, "name": name.strip(), @@ -2316,8 +2364,8 @@ def setup_model_routes(model_discovery): "api_key_fingerprint": _api_key_fingerprint(api_key), "models": _merge_model_ids(model_ids, _pinned), "pinned_models": _pinned, - "online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")), - "status": "online" if (model_ids or _pinned) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")), + "online": is_online, + "status": is_status, "ping_error": ping.get("error") if ping else None, "endpoint_kind": requested_kind, "category": _classify_endpoint(base_url, requested_kind), @@ -2339,14 +2387,19 @@ def setup_model_routes(model_discovery): base_url = resolve_url(base_url) base_url = _rewrite_loopback_for_docker(base_url) requested_kind = _normalize_endpoint_kind(endpoint_kind) + is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless" + if is_featherless and requested_kind == "auto": + requested_kind = "api" configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60) probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout) models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout) ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 10.0)) + is_online = bool(models) or bool(ping.get("reachable")) or (is_featherless and ping.get("reachable")) + is_status = "online" if (models or (is_featherless and ping.get("reachable"))) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")) return { "base_url": base_url, - "online": bool(models) or bool(ping.get("reachable")), - "status": "online" if models else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")), + "online": is_online, + "status": is_status, "ping_error": ping.get("error") if ping else None, "models": models, "count": len(models), @@ -2531,6 +2584,149 @@ def setup_model_routes(model_discovery): finally: db.close() + @router.get("/model-endpoints/{ep_id}/catalog-search") + async def search_endpoint_catalog( + ep_id: str, + request: Request, + q: str = Query(..., min_length=2, max_length=100), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=100), + ): + """Search catalog for large-inventory providers like Featherless.""" + require_admin(request) + q_clean = q.strip() + if len(q_clean) < 2: + raise HTTPException(400, "Search query must be at least 2 characters") + + db = SessionLocal() + try: + ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first() + if not ep or not _chatgpt_endpoint_visible(ep, request): + raise HTTPException(404, "Endpoint not found") + base = _normalize_base(ep.base_url) + is_featherless = _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless" + if not is_featherless: + raise HTTPException(400, "Catalog search is only supported for Featherless endpoints") + api_key = _resolve_probe_key(ep) or (ep.api_key.strip() if getattr(ep, "api_key", None) else None) + if not api_key: + raise HTTPException(400, "Featherless endpoint has no API key configured") + finally: + db.close() + + try: + page = max(int(page or 1), 1) + except Exception: + page = 1 + try: + per_page = min(max(int(per_page or 50), 1), 100) + except Exception: + per_page = 50 + + # In-memory cache check + cache_key = (ep_id, q_clean.lower(), page, per_page) + now = _time.time() + with _featherless_search_cache_lock: + cached_entry = _featherless_search_cache.get(cache_key) + if cached_entry: + ts, cached_data = cached_entry + if now - ts < 45.0: + return cached_data + else: + _featherless_search_cache.pop(cache_key, None) + + # Build upstream URL and params + models_url = f"{base}/models" if base.endswith("/v1") else f"{base.rstrip('/')}/v1/models" + params = { + "q": q_clean, + "available_on_current_plan": "true", + "status": "active", + "conversational": "true", + "page": page, + "per_page": per_page, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + } + + try: + async with httpx.AsyncClient(timeout=10.0, verify=llm_verify()) as client: + r = await client.get(models_url, params=params, headers=headers) + if r.status_code in (401, 403): + raise HTTPException(r.status_code, "Featherless API key invalid or unauthorized") + if r.status_code == 429: + raise HTTPException(429, "Featherless rate limit exceeded; please try again shortly") + if r.status_code >= 500: + raise HTTPException(502, f"Featherless upstream error: HTTP {r.status_code}") + if r.status_code >= 400: + raise HTTPException(r.status_code, f"Featherless API error: HTTP {r.status_code}") + data = r.json() + except httpx.HTTPStatusError as exc: + code = exc.response.status_code if exc.response is not None else 502 + if code in (401, 403): + raise HTTPException(code, "Featherless API key invalid or unauthorized") + if code == 429: + raise HTTPException(429, "Featherless rate limit exceeded; please try again shortly") + raise HTTPException(502 if code >= 500 else code, f"Featherless API error: HTTP {code}") + except httpx.TimeoutException: + raise HTTPException(504, "Featherless search request timed out") + except HTTPException: + raise + except Exception as exc: + logger.warning("Featherless catalog search failed: %s", exc) + raise HTTPException(502, f"Failed to connect to Featherless: {str(exc)[:120]}") + + raw_items = data.get("data") if isinstance(data, dict) else (data if isinstance(data, list) else []) + normalized_items = [] + for m in (raw_items or []): + if not isinstance(m, dict): + continue + m_id = m.get("id") + if not m_id or not isinstance(m_id, str): + continue + normalized_items.append({ + "id": m_id, + "name": m.get("name") or m_id, + "context_length": m.get("context_length"), + "max_completion_tokens": m.get("max_completion_tokens"), + "is_gated": bool(m.get("is_gated", False)), + "available_on_current_plan": bool(m.get("available_on_current_plan", True)), + }) + + total_val = None + if isinstance(data, dict): + for k in ("total", "count", "total_count"): + v = data.get(k) + if isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0: + total_val = int(v) + break + + if total_val is not None: + has_more = (page * per_page) < total_val and len(normalized_items) > 0 + else: + has_more = len(normalized_items) == per_page + + result = { + "items": normalized_items, + "page": page, + "per_page": per_page, + "has_more": has_more, + } + if total_val is not None: + result["total"] = total_val + + with _featherless_search_cache_lock: + if len(_featherless_search_cache) >= 200: + expired_keys = [k for k, (t, _) in _featherless_search_cache.items() if now - t >= 45.0] + for k in expired_keys: + _featherless_search_cache.pop(k, None) + if len(_featherless_search_cache) >= 200: + oldest_key = min(_featherless_search_cache.keys(), key=lambda k: _featherless_search_cache[k][0]) + _featherless_search_cache.pop(oldest_key, None) + _featherless_search_cache[cache_key] = (now, result) + + return result + @router.get("/default-chat") def get_default_chat(request: Request): # SECURITY: resolve the default endpoint + model from the CALLER's @@ -2844,4 +3040,6 @@ def setup_model_routes(model_discovery): _save_settings(settings) return {"ok": True, "disabled": body.disabled} + router._should_refresh_endpoint = _should_refresh_endpoint + router._search_endpoint_catalog = search_endpoint_catalog return router diff --git a/src/llm_core.py b/src/llm_core.py index 0d3abb09f..0908b56bb 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -1099,6 +1099,8 @@ def _detect_provider(url: str) -> str: from src.copilot import is_copilot_base if is_copilot_base(url): return "copilot" + if _host_match(url, "featherless.ai"): + return "featherless" if _host_match(url, "cerebras.ai"): return "cerebras" if _host_match(url, "mistral.ai"): @@ -1330,6 +1332,7 @@ def _provider_label(url: str) -> str: if is_chatgpt_subscription_base(url): return "ChatGPT Subscription" from src.copilot import is_copilot_base if is_copilot_base(url): return "GitHub Copilot" + if _host_match(url, "featherless.ai"): return "Featherless.ai" if _host_match(url, "cerebras.ai"): return "cerebras" if _host_match(url, "mistral.ai"): return "Mistral" diff --git a/static/index.html b/static/index.html index 1d117fe60..e54df3675 100644 --- a/static/index.html +++ b/static/index.html @@ -2314,6 +2314,7 @@ + diff --git a/static/js/admin.js b/static/js/admin.js index dd00c59d3..3e3463e9e 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -637,6 +637,298 @@ function endpointDetailHtml(ep, category) { return `
${parts.join('')}
`; } +function renderFeatherlessPanel(panel, ep, row) { + const epId = ep.id; + const initialPinned = Array.isArray(ep.pinned_models) + ? ep.pinned_models + : (typeof ep.pinned_models === 'string' ? JSON.parse(ep.pinned_models || '[]') : []); + const enabledSet = new Set(initialPinned); + const toolModes = typeof ep.model_tool_modes === 'object' && ep.model_tool_modes !== null + ? { ...ep.model_tool_modes } + : {}; + panel.dataset.pickerMode = 'pinned'; + + panel.innerHTML = `
+ Featherless Catalog +
+
+ +
+
+ Enabled models (${enabledSet.size}) +
+
+
+
+
+ Search results +
+
+ Type at least 2 characters to search over 20,000+ models. +
+ +
+
`; + + const searchInput = panel.querySelector('.featherless-search-input'); + const spinnerHost = panel.querySelector('.featherless-spinner-host'); + const enabledListEl = panel.querySelector('.featherless-enabled-list'); + const enabledCountSpan = panel.querySelector('.featherless-enabled-count'); + const resultsList = panel.querySelector('.featherless-results-list'); + const paginationHost = panel.querySelector('.featherless-pagination'); + const loadMoreBtn = panel.querySelector('.featherless-load-more'); + + const showSpinner = () => { if (spinnerHost) spinnerHost.style.display = 'inline-flex'; }; + const hideSpinner = () => { if (spinnerHost) spinnerHost.style.display = 'none'; }; + + const formatTokens = (tokens) => { + if (!tokens || typeof tokens !== 'number') return ''; + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`; + if (tokens >= 1000) return `${Math.round(tokens / 1024)}k`; + return String(tokens); + }; + + const updateHeaderCount = () => { + const countBadge = row ? row.querySelector(`[data-adm-ep-models-count="${epId}"]`) : null; + if (countBadge) { + countBadge.textContent = `${enabledSet.size} models enabled`; + } + if (enabledCountSpan) { + enabledCountSpan.textContent = String(enabledSet.size); + } + ep.pinned_models = Array.from(enabledSet); + }; + + const saveState = async () => { + try { + await fetch(`/api/model-endpoints/${epId}/models`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + pinned_models: Array.from(enabledSet), + model_tool_modes: toolModes, + }), + }); + if (typeof _refreshAfterEndpointChange === 'function') { + _refreshAfterEndpointChange(); + } + } catch (err) { + console.error('Failed to save Featherless model state', err); + } + }; + + const syncSearchCheckboxes = () => { + resultsList.querySelectorAll('input[data-featherless-search-id]').forEach(cb => { + const id = cb.dataset.featherlessSearchId; + cb.checked = enabledSet.has(id); + }); + }; + + const renderEnabledList = () => { + if (!enabledListEl) return; + if (enabledSet.size === 0) { + enabledListEl.innerHTML = 'No models enabled. Search below to add models.'; + return; + } + const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b)); + enabledListEl.innerHTML = sortedIds.map(id => { + const displayName = id.split('/').pop() || id; + const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase()) + ? String(toolModes[id]).toLowerCase() + : ''; + return `
+ +
+ Tools + +
+
`; + }).join(''); + + enabledListEl.querySelectorAll('input[data-featherless-enabled-id]').forEach(cb => { + cb.addEventListener('change', () => { + const id = cb.dataset.featherlessEnabledId; + if (!cb.checked) { + enabledSet.delete(id); + updateHeaderCount(); + renderEnabledList(); + syncSearchCheckboxes(); + saveState(); + } + }); + }); + + enabledListEl.querySelectorAll('.adm-model-tool-mode').forEach(sel => { + sel.addEventListener('change', () => { + const id = sel.dataset.epModelId; + const val = String(sel.value || '').toLowerCase(); + if (val) toolModes[id] = val; + else delete toolModes[id]; + saveState(); + }); + }); + }; + + renderEnabledList(); + + let currentQuery = ''; + let currentPage = 1; + let searchAbortController = null; + let searchTimeout = null; + let isSearching = false; + + const renderSearchResults = (items, append = false, hasMore = false) => { + if (!append) { + resultsList.innerHTML = ''; + } + if (!items || items.length === 0) { + if (!append) { + resultsList.innerHTML = 'No models found matching your search.'; + } + if (paginationHost) paginationHost.style.display = 'none'; + return; + } + + const itemsHtml = items.map(item => { + const isChecked = enabledSet.has(item.id); + const displayName = item.name || item.id; + const ctx = item.context_length ? `${formatTokens(item.context_length)} ctx` : ''; + return `
+ +
`; + }).join(''); + + if (append) { + resultsList.insertAdjacentHTML('beforeend', itemsHtml); + } else { + resultsList.innerHTML = itemsHtml; + } + + resultsList.querySelectorAll('input[data-featherless-search-id]').forEach(cb => { + if (cb.dataset.listenerAttached) return; + cb.dataset.listenerAttached = '1'; + cb.addEventListener('change', () => { + const id = cb.dataset.featherlessSearchId; + if (cb.checked) { + enabledSet.add(id); + } else { + enabledSet.delete(id); + } + updateHeaderCount(); + renderEnabledList(); + syncSearchCheckboxes(); + saveState(); + }); + }); + + if (paginationHost) { + paginationHost.style.display = hasMore ? '' : 'none'; + } + }; + + if (searchInput) { + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + } + }); + + searchInput.addEventListener('input', () => { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = setTimeout(async () => { + const q = searchInput.value.trim(); + if (q.length < 2) { + if (searchAbortController) searchAbortController.abort(); + hideSpinner(); + resultsList.innerHTML = 'Type at least 2 characters to search over 20,000+ models.'; + if (paginationHost) paginationHost.style.display = 'none'; + return; + } + + if (searchAbortController) { + searchAbortController.abort(); + } + searchAbortController = new AbortController(); + currentQuery = q; + currentPage = 1; + showSpinner(); + + try { + const res = await fetch(`/api/model-endpoints/${epId}/catalog-search?q=${encodeURIComponent(q)}&page=1&per_page=50`, { + credentials: 'same-origin', + signal: searchAbortController.signal, + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || `HTTP ${res.status}`); + } + const data = await res.json(); + renderSearchResults(data.items, false, data.has_more); + } catch (err) { + if (err.name === 'AbortError') return; + resultsList.innerHTML = `Search failed: ${esc(err.message)}`; + if (paginationHost) paginationHost.style.display = 'none'; + } finally { + hideSpinner(); + } + }, 250); + }); + } + + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!currentQuery || isSearching) return; + isSearching = true; + loadMoreBtn.disabled = true; + loadMoreBtn.textContent = 'Loading...'; + currentPage += 1; + + try { + const res = await fetch(`/api/model-endpoints/${epId}/catalog-search?q=${encodeURIComponent(currentQuery)}&page=${currentPage}&per_page=50`, { + credentials: 'same-origin', + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || `HTTP ${res.status}`); + } + const data = await res.json(); + renderSearchResults(data.items, true, data.has_more); + } catch (err) { + if (typeof uiModule !== 'undefined' && uiModule?.showToast) { + uiModule.showToast(`Failed to load more models: ${err.message}`, 4000); + } + } finally { + isSearching = false; + loadMoreBtn.disabled = false; + loadMoreBtn.textContent = 'Load more'; + } + }); + } +} + // ChatGPT per-endpoint usage panel expanded state persistence. // Preserves only endpoint/auth identifiers, never tokens, secrets, or labels. const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded'; @@ -737,7 +1029,7 @@ async function loadEndpoints() { const statusBadge = ep.status === 'empty' ? 'no models' : ep.online - ? `${countText}` + ? `${countText}` : 'offline'; const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : ''; const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api'); @@ -961,7 +1253,7 @@ async function loadEndpoints() { // Don't let interactions inside the expanded panel re-fire the // expand/collapse handler — the search box was getting closed // because clicking it bubbled up to here. - if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, select, label')) return; + if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, select, label, button, .featherless-panel, .featherless-search-bar, [data-ep-model-row]')) return; const epId = header.dataset.admEpHeader; const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`); if (!panel) return; @@ -974,6 +1266,11 @@ async function loadEndpoints() { } if (!_modelsLoaded && isOpen) { _modelsLoaded = true; + const ep = data.find(x => String(x.id) === String(epId)); + if (ep && isFeatherlessEndpoint(ep)) { + renderFeatherlessPanel(panel, ep, row); + return; + } // Our shared whirlpool spinner (consistent with the rest of the app). panel.innerHTML = ''; let _modelsSpin = null; @@ -1509,6 +1806,12 @@ function initEndpointForm() { } function _renderEndpointTestResult(msg, res, d) { + const isFeatherless = d && d.base_url && /featherless\.ai/i.test(d.base_url); + if (res.ok && isFeatherless && d.online) { + msg.textContent = 'Online — Featherless.ai catalog ready (search to enable models)'; + msg.className = 'admin-success'; + return; + } if (res.ok && d.status === 'empty') { msg.textContent = 'Online — no models found'; msg.className = 'admin-success'; @@ -1630,9 +1933,13 @@ function initEndpointForm() { await loadEndpoints(); await _selectAddedModelInChat(d); const goLink = ' Added Models →'; + const isFeatherless = d && d.base_url && /featherless\.ai/i.test(d.base_url); if (!d.online) { msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink; msg.className = 'admin-error'; + } else if (isFeatherless) { + msg.innerHTML = 'Added Featherless.ai — search catalog to enable models' + goLink; + msg.className = 'admin-success'; } else if (d.status === 'empty') { msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink; msg.className = 'admin-success'; @@ -3809,7 +4116,7 @@ export function close() { settingsModule.close(); } -export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }; +export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml, renderFeatherlessPanel }; const adminModule = { open, close, _initData, get _initialized() { return initialized; } }; export default adminModule; diff --git a/static/js/providers.js b/static/js/providers.js index 54556faeb..c3c3c3638 100644 --- a/static/js/providers.js +++ b/static/js/providers.js @@ -87,6 +87,9 @@ const _PROVIDERS = [ // NVIDIA / Nemotron (official Simple Icons) [/nvidia|nemotron/i, ''], + // Featherless AI (feather icon) + [/featherless/i, + ''], ]; // Returns an SVG string for the given model ID, or null if no match @@ -107,6 +110,7 @@ export function providerLogo(modelId) { const _ENDPOINT_LABELS = [ [/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"], [/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"], + [/(^|\.)featherless\.ai$/i, "Featherless.ai"], [/(^|\.)openrouter\.ai$/i, "OpenRouter"], [/(^|\.)anthropic\.com$/i, "Anthropic"], [/(^|\.)openai\.com$/i, "OpenAI"], diff --git a/tests/test_featherless_provider.py b/tests/test_featherless_provider.py new file mode 100644 index 000000000..7aff586c7 --- /dev/null +++ b/tests/test_featherless_provider.py @@ -0,0 +1,666 @@ +"""Tests for Featherless provider detection, setup, lazy discovery, and catalog search.""" + +import asyncio +import json +import shutil +import subprocess +import sys +import time +import types +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi import HTTPException + +from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state + +with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"): + clear_fake_endpoint_resolver_modules() + + if "core.database" not in sys.modules: + _core_db = types.ModuleType("core.database") + for _name in [ + "SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document", + "DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", + "CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", + "McpServer", "ProviderAuthSession", "Base", + ]: + setattr(_core_db, _name, MagicMock()) + _core_db.utcnow_naive = MagicMock() + sys.modules["core.database"] = _core_db + + import routes.model_routes as model_routes + import src.llm_core as llm_core + from routes.model_routes import ( + _effective_endpoint_kind, + _probe_endpoint, + _ping_endpoint, + _picker_requires_pinning, + _has_explicit_pinned_models, + _picker_models_for_endpoint, + _featherless_search_cache, + _featherless_search_cache_lock, + ) + from src.llm_core import ( + _detect_provider, + _provider_label, + _is_self_hosted_openai_compatible, + ) + +_REPO = Path(__file__).resolve().parent.parent +_ADMIN_JS = _REPO / "static" / "js" / "admin.js" +_ROUTER = model_routes.setup_model_routes(model_discovery=None) +_should_refresh_endpoint = _ROUTER._should_refresh_endpoint +search_endpoint_catalog = _ROUTER._search_endpoint_catalog + + +def _route_endpoint(router, path, method="GET"): + for route in router.routes: + if getattr(route, "path", "") == path and method in getattr(route, "methods", set()): + return route.endpoint + raise AssertionError(f"{method} {path} route not found") + + +# ============================================================ +# 1. Provider Detection & Identification +# ============================================================ + +def test_featherless_provider_detection(): + url = "https://api.featherless.ai/v1" + assert _detect_provider(url) == "featherless" + assert _provider_label(url) == "Featherless.ai" + assert _is_self_hosted_openai_compatible(url) is False + + subdomain_url = "https://eu.featherless.ai/v1" + assert _detect_provider(subdomain_url) == "featherless" + assert _provider_label(subdomain_url) == "Featherless.ai" + assert _is_self_hosted_openai_compatible(subdomain_url) is False + + +def test_featherless_endpoint_kind_is_api_not_proxy(): + ep = SimpleNamespace(endpoint_kind="auto", api_key="sk-test-key") + url = "https://api.featherless.ai/v1" + # Keyed /v1 URLs normally resolve to 'proxy', but Featherless must resolve to 'api' + assert _effective_endpoint_kind(ep, url) == "api" + + +# ============================================================ +# 2. Probing & Setup Validation +# ============================================================ + +def test_featherless_probe_endpoint_bypasses_full_catalog(): + with patch("httpx.get") as mock_get: + models = _probe_endpoint("https://api.featherless.ai/v1", api_key="sk-test-key") + # Probe must immediately return [] without making any HTTP request to fetch 20k+ models + assert models == [] + mock_get.assert_not_called() + + +def test_featherless_ping_endpoint_plan_success(): + resp_plan = MagicMock() + resp_plan.status_code = 200 + resp_plan.text = '{"plan": "pro"}' + + with patch("httpx.get", return_value=resp_plan) as mock_get: + res = _ping_endpoint("https://api.featherless.ai/v1", api_key="sk-test-key") + assert res["reachable"] is True + assert res["status_code"] == 200 + assert res["error"] is None + mock_get.assert_called_once() + assert "plan" in mock_get.call_args[0][0] + + +def test_featherless_ping_endpoint_plan_fallback_to_models(): + # If /v1/plan returns 404, fallback to /v1/models with per_page=1 + resp_404 = MagicMock() + resp_404.status_code = 404 + resp_404.text = "Not found" + + resp_models = MagicMock() + resp_models.status_code = 200 + resp_models.text = '{"data": [{"id": "model1"}]}' + + with patch("httpx.get", side_effect=[resp_404, resp_models]) as mock_get: + res = _ping_endpoint("https://api.featherless.ai/v1", api_key="sk-test-key") + assert res["reachable"] is True + assert res["status_code"] == 200 + assert mock_get.call_count == 2 + assert "available_on_current_plan=true" in mock_get.call_args_list[1][0][0] + assert "per_page=1" in mock_get.call_args_list[1][0][0] + + +def test_featherless_ping_endpoint_auth_failure(): + resp_401 = MagicMock() + resp_401.status_code = 401 + resp_401.text = "Unauthorized" + + with patch("httpx.get", return_value=resp_401): + res = _ping_endpoint("https://api.featherless.ai/v1", api_key="bad-key") + assert res["reachable"] is False + assert res["status_code"] == 401 + assert "Featherless API key invalid or unauthorized" in res["error"] + + +# ============================================================ +# 3. Background Refresh & Catalog Protection +# ============================================================ + +def test_featherless_should_refresh_endpoint_returns_false(): + ep = SimpleNamespace( + id="ep-fl", + base_url="https://api.featherless.ai/v1", + api_key="sk-test", + provider_auth_id=None, + cached_models=None, + pinned_models="[]", + ) + should_refresh, info = _should_refresh_endpoint(ep, time.time()) + assert should_refresh is False + assert info["base"] == "https://api.featherless.ai/v1" + + +# ============================================================ +# 4. Pinning & Chat Picker Isolation +# ============================================================ + +def test_featherless_picker_models_initially_empty(): + url = "https://api.featherless.ai/v1" + kind = "api" + assert _picker_requires_pinning(url, kind) is True + + ep = SimpleNamespace( + base_url=url, + endpoint_kind=kind, + pinned_models="[]", + cached_models=None, + hidden_models=None, + ) + assert _has_explicit_pinned_models(ep) is True + visible, pinned = _picker_models_for_endpoint(ep, url, kind) + # Default state has 0 models enabled + assert visible == [] + assert pinned == [] + + +def test_featherless_picker_models_reflects_pinned_only(): + url = "https://api.featherless.ai/v1" + kind = "api" + ep = SimpleNamespace( + base_url=url, + endpoint_kind=kind, + pinned_models=json.dumps(["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Llama-3-8B-Instruct"]), + cached_models=None, + hidden_models=None, + ) + visible, pinned = _picker_models_for_endpoint(ep, url, kind) + assert visible == ["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Llama-3-8B-Instruct"] + assert pinned == ["mistralai/Mistral-7B-Instruct-v0.2", "meta-llama/Llama-3-8B-Instruct"] + + +# ============================================================ +# 5. Catalog Search Route +# ============================================================ + +@pytest.mark.asyncio +async def test_featherless_catalog_search_validation(): + # q < 2 chars raises HTTPException(400) + req = MagicMock() + with pytest.raises(HTTPException) as exc_info: + await search_endpoint_catalog("ep-1", req, q="a") + assert exc_info.value.status_code == 400 + assert "at least 2 characters" in exc_info.value.detail + + +class _FakeQuery: + def __init__(self, ep): + self.ep = ep + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def all(self): + return [self.ep] if self.ep else [] + + def first(self): + return self.ep + + +class _FakeDb: + def __init__(self, ep): + self.ep = ep + + def query(self, *args, **kwargs): + return _FakeQuery(self.ep) + + def close(self): + pass + + +def test_create_featherless_endpoint(monkeypatch): + create = _route_endpoint(_ROUTER, "/api/model-endpoints", "POST") + added = [] + class FakeDb: + def __init__(self): + self.added = added + def query(self, *args, **kwargs): + return _FakeQuery(None) + def add(self, row): + self.added.append(row) + def commit(self): + pass + def close(self): + pass + + monkeypatch.setattr(model_routes, "SessionLocal", FakeDb) + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **kw: {"reachable": True, "error": None}) + monkeypatch.setattr(model_routes, "_load_settings", lambda: {}) + monkeypatch.setattr(model_routes, "_save_settings", lambda s: None) + + req = MagicMock() + result = create( + req, + base_url="https://api.featherless.ai/v1", + name="", + api_key="sk-test", + skip_probe="false", + require_models="false", + model_type="llm", + endpoint_kind="auto", + model_refresh_mode="", + model_refresh_interval="", + model_refresh_timeout="", + supports_tools="", + pinned_models="", + container_local="false", + shared="true", + ) + + assert result["name"] == "Featherless.ai" + assert result["endpoint_kind"] == "api" + assert result["pinned_models"] == [] + assert result["models"] == [] + assert result["online"] is True + assert result["status"] == "online" + + assert len(added) == 1 + ep = added[0] + assert ep.name == "Featherless.ai" + assert ep.endpoint_kind == "api" + assert ep.pinned_models == "[]" + assert ep.cached_models is None + + +def test_list_featherless_endpoint(monkeypatch): + list_ep = _route_endpoint(_ROUTER, "/api/model-endpoints", "GET") + ep = SimpleNamespace( + id="ep-fl", + name="Featherless.ai", + base_url="https://api.featherless.ai/v1", + api_key="sk-test", + is_enabled=True, + cached_models=None, + pinned_models="[]", + hidden_models=None, + endpoint_kind="api", + model_type="llm", + supports_tools=None, + model_refresh_mode="auto", + model_refresh_interval=None, + model_refresh_timeout=None, + owner=None, + created_at=None, + updated_at=None, + ) + class FakeDb: + def query(self, *args, **kwargs): + m = MagicMock() + m.order_by.return_value.all.return_value = [ep] + return m + def close(self): + pass + + monkeypatch.setattr(model_routes, "SessionLocal", FakeDb) + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False) + + req = MagicMock() + results = list_ep(req) + assert len(results) == 1 + r = results[0] + assert r["name"] == "Featherless.ai" + assert r["status"] == "online" + assert r["online"] is True + assert r["model_count"] == 0 + assert r["models"] == [] + assert r["pinned_models"] == [] + + +@pytest.mark.asyncio +async def test_featherless_catalog_search_non_featherless_endpoint(monkeypatch): + req = MagicMock() + ep_mock = SimpleNamespace( + id="ep-openai", + base_url="https://api.openai.com/v1", + api_key="sk-test", + ) + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock)) + + with pytest.raises(HTTPException) as exc_info: + await search_endpoint_catalog("ep-openai", req, q="gpt") + assert exc_info.value.status_code == 400 + assert "only supported for Featherless" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_featherless_catalog_search_success_and_caching(monkeypatch): + req = MagicMock() + ep_mock = SimpleNamespace( + id="ep-fl", + base_url="https://api.featherless.ai/v1", + api_key="sk-test-key", + ) + + upstream_data = { + "data": [ + { + "id": "mistralai/Mistral-7B-Instruct-v0.2", + "name": "Mistral 7B Instruct v0.2", + "context_length": 32768, + "max_completion_tokens": 8192, + "is_gated": False, + "available_on_current_plan": True, + }, + { + "id": "meta-llama/Meta-Llama-3-8B-Instruct", + "context_length": 8192, + }, + ] + } + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = upstream_data + + # Clear cache before test + with _featherless_search_cache_lock: + _featherless_search_cache.clear() + + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock)) + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp) as mock_async_get: + res1 = await search_endpoint_catalog("ep-fl", req, q="mistral", page=1, per_page=50) + assert len(res1["items"]) == 2 + assert res1["items"][0]["id"] == "mistralai/Mistral-7B-Instruct-v0.2" + assert res1["items"][0]["context_length"] == 32768 + assert res1["items"][1]["name"] == "meta-llama/Meta-Llama-3-8B-Instruct" + assert res1["page"] == 1 + assert res1["per_page"] == 50 + assert mock_async_get.call_count == 1 + + # Check upstream call parameters + call_kwargs = mock_async_get.call_args[1] + assert call_kwargs["params"]["q"] == "mistral" + assert "search" not in call_kwargs["params"] + assert call_kwargs["params"]["available_on_current_plan"] == "true" + assert call_kwargs["params"]["status"] == "active" + assert call_kwargs["params"]["conversational"] == "true" + assert call_kwargs["headers"]["Authorization"] == "Bearer sk-test-key" + + # Second call with same query should hit in-memory cache without calling upstream + res2 = await search_endpoint_catalog("ep-fl", req, q="mistral", page=1, per_page=50) + assert res2 == res1 + assert mock_async_get.call_count == 1 # Not incremented! + + +@pytest.mark.asyncio +async def test_featherless_catalog_search_exact_upstream_params(monkeypatch): + """Proves exact upstream query parameters: q (not search), filters, page, bounded per_page.""" + req = MagicMock() + ep_mock = SimpleNamespace( + id="ep-fl", + base_url="https://api.featherless.ai/v1", + api_key="sk-test-key", + ) + + with _featherless_search_cache_lock: + _featherless_search_cache.clear() + + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock)) + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"data": [{"id": "deepseek-ai/DeepSeek-V3"}]} + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp) as mock_async_get: + # Standard query + await search_endpoint_catalog("ep-fl", req, q="deepseek", page=2, per_page=50) + assert mock_async_get.call_count == 1 + call_kwargs = mock_async_get.call_args[1] + params = call_kwargs["params"] + + assert params["q"] == "deepseek" + assert "search" not in params + assert params["available_on_current_plan"] == "true" + assert params["status"] == "active" + assert params["conversational"] == "true" + assert params["page"] == 2 + assert params["per_page"] == 50 + + # Bounded per_page: upper bound (500 -> 100) + await search_endpoint_catalog("ep-fl", req, q="deepseek-high", page=1, per_page=500) + params_upper = mock_async_get.call_args[1]["params"] + assert params_upper["per_page"] == 100 + + # Bounded page and per_page: lower bound (page 0 -> 1, per_page -5 -> 1) + await search_endpoint_catalog("ep-fl", req, q="deepseek-low", page=0, per_page=-5) + params_lower = mock_async_get.call_args[1]["params"] + assert params_lower["page"] == 1 + assert params_lower["per_page"] == 1 + + +@pytest.mark.asyncio +async def test_featherless_catalog_pagination_defensive_behavior(monkeypatch): + """Tests defensive pagination: total/count metadata vs data-only fallback.""" + req = MagicMock() + ep_mock = SimpleNamespace( + id="ep-fl", + base_url="https://api.featherless.ai/v1", + api_key="sk-test-key", + ) + + with _featherless_search_cache_lock: + _featherless_search_cache.clear() + + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock)) + + mock_resp = MagicMock() + mock_resp.status_code = 200 + + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp) as mock_async_get: + # Case 1: Response with total metadata (page 1 * 50 = 50 < 120 => has_more=True) + mock_resp.json.return_value = { + "data": [{"id": f"model-{i}"} for i in range(50)], + "total": 120, + } + res1 = await search_endpoint_catalog("ep-fl", req, q="query1", page=1, per_page=50) + assert res1["has_more"] is True + assert res1["total"] == 120 + assert len(res1["items"]) == 50 + + # Case 2: Response with total metadata reached (page 1 * 50 = 50 >= 50 => has_more=False) + mock_resp.json.return_value = { + "data": [{"id": f"model-{i}"} for i in range(50)], + "total": 50, + } + res2 = await search_endpoint_catalog("ep-fl", req, q="query2", page=1, per_page=50) + assert res2["has_more"] is False + assert res2["total"] == 50 + + # Case 3: Response with count metadata reached (page 2 * 50 = 100 >= 80 => has_more=False) + mock_resp.json.return_value = { + "data": [{"id": f"model-{i}"} for i in range(30)], + "count": 80, + } + res3 = await search_endpoint_catalog("ep-fl", req, q="query3", page=2, per_page=50) + assert res3["has_more"] is False + assert res3["total"] == 80 + + # Case 4: Response containing ONLY {"data": [...]} with exactly per_page items => conservative has_more=True + mock_resp.json.return_value = { + "data": [{"id": f"model-{i}"} for i in range(50)], + } + res4 = await search_endpoint_catalog("ep-fl", req, q="query4", page=1, per_page=50) + assert res4["has_more"] is True + assert "total" not in res4 + assert len(res4["items"]) == 50 + + # Case 5: Response containing ONLY {"data": [...]} with fewer than per_page items => has_more=False + mock_resp.json.return_value = { + "data": [{"id": f"model-{i}"} for i in range(49)], + } + res5 = await search_endpoint_catalog("ep-fl", req, q="query5", page=1, per_page=50) + assert res5["has_more"] is False + assert "total" not in res5 + assert len(res5["items"]) == 49 + + # Case 6: Response containing ONLY {"data": []} => has_more=False + mock_resp.json.return_value = { + "data": [], + } + res6 = await search_endpoint_catalog("ep-fl", req, q="query6", page=1, per_page=50) + assert res6["has_more"] is False + assert "total" not in res6 + assert len(res6["items"]) == 0 + + +@pytest.mark.asyncio +async def test_featherless_catalog_search_error_handling(monkeypatch): + req = MagicMock() + ep_mock = SimpleNamespace( + id="ep-fl", + base_url="https://api.featherless.ai/v1", + api_key="sk-test-key", + ) + + monkeypatch.setattr(model_routes, "require_admin", lambda r: None) + monkeypatch.setattr(model_routes, "_chatgpt_endpoint_visible", lambda ep, req: True) + monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDb(ep_mock)) + + # 401 Unauthorized + resp_401 = MagicMock() + resp_401.status_code = 401 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp_401): + with pytest.raises(HTTPException) as exc_401: + await search_endpoint_catalog("ep-fl", req, q="llama") + assert exc_401.value.status_code == 401 + assert "API key invalid" in exc_401.value.detail + + # 429 Rate Limit + resp_429 = MagicMock() + resp_429.status_code = 429 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp_429): + with pytest.raises(HTTPException) as exc_429: + await search_endpoint_catalog("ep-fl", req, q="llama") + assert exc_429.value.status_code == 429 + assert "rate limit" in exc_429.value.detail.lower() + + # 504 Timeout + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=httpx.TimeoutException("Timeout")): + with pytest.raises(HTTPException) as exc_504: + await search_endpoint_catalog("ep-fl", req, q="llama") + assert exc_504.value.status_code == 504 + + +# ============================================================ +# 6. Frontend JS Tests (Node) +# ============================================================ + +@pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") +class TestFeatherlessFrontend: + def test_featherless_js_panel_and_helpers(self): + js = f""" + import fs from 'node:fs'; + import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}'; + const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8'); + const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl'); + const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart); + const fnCode = source.slice(fnStart, fnEnd); + const fns = new Function('isChatgptSubscriptionEndpoint', 'esc', + fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, renderFeatherlessPanel }};' + )(isChatgptSubscriptionEndpoint, x => String(x)); + + const ep = {{ + id: 'fl-1', + base_url: 'https://api.featherless.ai/v1', + provider: 'featherless', + pinned_models: ['mistralai/Mistral-7B-Instruct-v0.2'] + }}; + + const isFl = fns.isFeatherlessEndpoint(ep); + const showUrl = fns.shouldDisplayEndpointBaseUrl(ep); + + // Test renderFeatherlessPanel DOM construction + const mockPanel = {{ + dataset: {{}}, + innerHTML: '', + querySelector: function(sel) {{ + if (sel === '.featherless-search-input') return {{ addEventListener: () => {{}}, value: '' }}; + if (sel === '.featherless-enabled-list') return {{ innerHTML: '', querySelectorAll: () => [] }}; + if (sel === '.featherless-enabled-count') return {{ textContent: '' }}; + if (sel === '.featherless-results-list') return {{ innerHTML: '', querySelectorAll: () => [] }}; + if (sel === '.featherless-pagination') return {{ style: {{}} }}; + if (sel === '.featherless-load-more') return {{ addEventListener: () => {{}} }}; + if (sel === '.featherless-spinner-host') return {{ style: {{}} }}; + return null; + }}, + querySelectorAll: function() {{ return []; }} + }}; + + const mockRow = {{ + querySelector: function() {{ return {{ textContent: '' }}; }} + }}; + + fns.renderFeatherlessPanel(mockPanel, ep, mockRow); + + console.log(JSON.stringify({{ + isFl, + showUrl, + pickerMode: mockPanel.dataset.pickerMode, + hasHeader: mockPanel.innerHTML.includes('Featherless Catalog'), + hasSearchBar: mockPanel.innerHTML.includes('featherless-search-bar'), + hasEnabledSection: mockPanel.innerHTML.includes('featherless-enabled-section'), + hasResultsSection: mockPanel.innerHTML.includes('featherless-results-section'), + }})); + """ + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + data = json.loads(proc.stdout.strip()) + assert data["isFl"] is True + assert data["showUrl"] is False + assert data["pickerMode"] == "pinned" + assert data["hasHeader"] is True + assert data["hasSearchBar"] is True + assert data["hasEnabledSection"] is True + assert data["hasResultsSection"] is True