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 = `
+
+
+
+ Searching...
+
+
+
+ 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