feat(provider): add lazy Featherless model discovery

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent 2b2bf0fb90
commit 8bb48780a1
6 changed files with 1194 additions and 15 deletions
+210 -12
View File
@@ -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
+3
View File
@@ -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"
+1
View File
@@ -2314,6 +2314,7 @@
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
<option value="https://api.featherless.ai/v1" data-logo="featherless">Featherless.ai</option>
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
+310 -3
View File
@@ -637,6 +637,298 @@ function endpointDetailHtml(ep, category) {
return `<div class="admin-ep-detail">${parts.join('')}</div>`;
}
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 = `<div class="mcp-tools-header">
<span>Featherless Catalog</span>
</div>
<div class="featherless-panel" style="display:flex;flex-direction:column;gap:12px;padding:6px 0;">
<div class="featherless-search-bar" style="position:relative;display:flex;align-items:center;">
<input type="search" class="mcp-tools-search featherless-search-input" placeholder="Search Featherless models (min 2 chars)..." style="width:100%;box-sizing:border-box;" data-featherless-search="${esc(epId)}">
<span class="featherless-spinner-host" style="display:none;position:absolute;right:8px;font-size:10px;opacity:0.55;">Searching...</span>
</div>
<div class="featherless-enabled-section">
<div style="font-size:11px;font-weight:600;opacity:0.8;margin-bottom:4px;">
Enabled models (<span class="featherless-enabled-count">${enabledSet.size}</span>)
</div>
<div class="featherless-enabled-list mcp-tools-list" style="max-height:160px;overflow-y:auto;"></div>
</div>
<div class="featherless-results-section">
<div style="font-size:11px;font-weight:600;opacity:0.8;margin-bottom:4px;">
Search results
</div>
<div class="featherless-results-list mcp-tools-list" style="max-height:280px;overflow-y:auto;">
<span class="featherless-search-hint" style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">Type at least 2 characters to search over 20,000+ models.</span>
</div>
<div class="featherless-pagination" style="display:none;margin-top:6px;text-align:center;">
<button type="button" class="admin-btn-sm featherless-load-more" style="width:100%;">Load more</button>
</div>
</div>
</div>`;
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 = '<span style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">No models enabled. Search below to add models.</span>';
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 `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
<label class="adm-model-label">
<input type="checkbox" class="adm-cb-hidden" data-featherless-enabled-id="${esc(id)}" checked>
<span class="adm-check-dot" aria-hidden="true"></span>
<span class="adm-model-name">${esc(displayName)}</span>
</label>
<div class="adm-model-tools-col">
<span class="adm-model-tools-label" title="Select the tool schema profile for this model">Tools</span>
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
</div>`;
}).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 = '<span style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">No models found matching your search.</span>';
}
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 `<div title="${esc(item.id)}" data-ep-model-row data-model-id="${esc(item.id)}" class="adm-model-row">
<label class="adm-model-label" style="width:100%;">
<input type="checkbox" class="adm-cb-hidden" data-featherless-search-id="${esc(item.id)}" ${isChecked ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
<span class="adm-model-name" style="flex:1;">${esc(displayName)}</span>
${ctx ? `<span class="admin-badge" style="margin-left:auto;font-size:9px;opacity:0.6;">${esc(ctx)}</span>` : ''}
</label>
</div>`;
}).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 = '<span class="featherless-search-hint" style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">Type at least 2 characters to search over 20,000+ models.</span>';
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 = `<span class="admin-error" style="font-size:11px;padding:4px 0;display:block;">Search failed: ${esc(err.message)}</span>`;
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'
? '<span class="admin-badge">no models</span>'
: ep.online
? `<span class="admin-badge">${countText}</span>`
? `<span class="admin-badge" data-adm-ep-models-count="${ep.id}">${countText}</span>`
: '<span class="admin-badge admin-badge-off">offline</span>';
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 = ' <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>';
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;
+4
View File
@@ -87,6 +87,9 @@ const _PROVIDERS = [
// NVIDIA / Nemotron (official Simple Icons)
[/nvidia|nemotron/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>'],
// Featherless AI (feather icon)
[/featherless/i,
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/><line x1="16" y1="8" x2="2" y2="22"/><line x1="17.5" y1="15" x2="9" y2="15"/></svg>'],
];
// 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"],
+666
View File
@@ -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