mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
feat(provider): add lazy Featherless model discovery
This commit is contained in:
+210
-12
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user