mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-17 20:32: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 socket
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any, Optional
|
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 pydantic import BaseModel
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
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:
|
try:
|
||||||
from core.log_safety import redact_url as _redact_url_for_log
|
from core.log_safety import redact_url as _redact_url_for_log
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
@@ -854,6 +858,8 @@ def _effective_endpoint_kind(ep: Any, base_url: str) -> str:
|
|||||||
kind = _endpoint_kind(ep)
|
kind = _endpoint_kind(ep)
|
||||||
if kind != "auto":
|
if kind != "auto":
|
||||||
return kind
|
return kind
|
||||||
|
if _host_match(base_url, "featherless.ai"):
|
||||||
|
return "api"
|
||||||
if getattr(ep, "api_key", None) and not _is_ollama_base(base_url):
|
if getattr(ep, "api_key", None) and not _is_ollama_base(base_url):
|
||||||
try:
|
try:
|
||||||
path = (urlparse(base_url).path or "").rstrip("/")
|
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:
|
if api_key:
|
||||||
return fetch_available_models(api_key, timeout=timeout)
|
return fetch_available_models(api_key, timeout=timeout)
|
||||||
return []
|
return []
|
||||||
|
if provider == "featherless" or _host_match(base, "featherless.ai"):
|
||||||
|
return []
|
||||||
if _is_google_api_base(base):
|
if _is_google_api_base(base):
|
||||||
try:
|
try:
|
||||||
models = _probe_google_models(base, api_key, timeout=timeout)
|
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
|
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:
|
try:
|
||||||
if looks_like_ollama:
|
if looks_like_ollama:
|
||||||
root = base
|
root = base
|
||||||
@@ -1517,6 +1550,8 @@ def setup_model_routes(model_discovery):
|
|||||||
}
|
}
|
||||||
if not base:
|
if not base:
|
||||||
return False, info
|
return False, info
|
||||||
|
if _host_match(base, "featherless.ai") or _safe_detect_provider(base) == "featherless":
|
||||||
|
return False, info
|
||||||
if state.get("inflight"):
|
if state.get("inflight"):
|
||||||
return False, info
|
return False, info
|
||||||
if mode in ("manual", "disabled") and not force:
|
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):
|
if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r):
|
||||||
r.pinned_models = json.dumps(pinned)
|
r.pinned_models = json.dumps(pinned)
|
||||||
upgraded_legacy_pins = True
|
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)
|
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({
|
results.append({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
@@ -2114,11 +2150,19 @@ def setup_model_routes(model_discovery):
|
|||||||
# keep those container-local when the frontend marks them as such.
|
# keep those container-local when the frontend marks them as such.
|
||||||
base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local))
|
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
|
# Auto-generate name from URL if not provided
|
||||||
if not name.strip():
|
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)
|
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_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_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400)
|
||||||
refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
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_models = _cached_model_ids(existing)
|
||||||
_existing_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None))
|
_existing_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None))
|
||||||
existing_kind = _effective_endpoint_kind(existing, existing.base_url)
|
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 {
|
return {
|
||||||
"id": existing.id,
|
"id": existing.id,
|
||||||
"name": existing.name,
|
"name": existing.name,
|
||||||
@@ -2224,8 +2270,8 @@ def setup_model_routes(model_discovery):
|
|||||||
existing.pinned_models,
|
existing.pinned_models,
|
||||||
),
|
),
|
||||||
"pinned_models": _existing_pinned,
|
"pinned_models": _existing_pinned,
|
||||||
"online": True,
|
"online": existing_status != "offline",
|
||||||
"status": "online",
|
"status": existing_status,
|
||||||
"existing": True,
|
"existing": True,
|
||||||
"endpoint_kind": existing_kind,
|
"endpoint_kind": existing_kind,
|
||||||
"category": _classify_endpoint(existing.base_url, 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}
|
ping = {"reachable": False, "error": None}
|
||||||
if (should_probe or requested_kind in ("api", "proxy")) and not model_ids:
|
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))
|
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))
|
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
|
||||||
|
|
||||||
ep_id = str(uuid.uuid4())[:8]
|
ep_id = str(uuid.uuid4())[:8]
|
||||||
@@ -2267,8 +2313,8 @@ def setup_model_routes(model_discovery):
|
|||||||
model_refresh_mode=refresh_mode,
|
model_refresh_mode=refresh_mode,
|
||||||
model_refresh_interval=refresh_interval,
|
model_refresh_interval=refresh_interval,
|
||||||
model_refresh_timeout=refresh_timeout,
|
model_refresh_timeout=refresh_timeout,
|
||||||
cached_models=json.dumps(model_ids) if model_ids else None,
|
cached_models=None if is_featherless else (json.dumps(model_ids) if model_ids else None),
|
||||||
pinned_models=json.dumps(_pinned) if _pinned else None,
|
pinned_models=json.dumps(_pinned) if (is_featherless or _pinned) else None,
|
||||||
supports_tools=_st,
|
supports_tools=_st,
|
||||||
owner=_owner_val,
|
owner=_owner_val,
|
||||||
)
|
)
|
||||||
@@ -2308,6 +2354,8 @@ def setup_model_routes(model_discovery):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Return immediately — probing happens via the separate /probe SSE endpoint
|
# 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 {
|
return {
|
||||||
"id": ep_id,
|
"id": ep_id,
|
||||||
"name": name.strip(),
|
"name": name.strip(),
|
||||||
@@ -2316,8 +2364,8 @@ def setup_model_routes(model_discovery):
|
|||||||
"api_key_fingerprint": _api_key_fingerprint(api_key),
|
"api_key_fingerprint": _api_key_fingerprint(api_key),
|
||||||
"models": _merge_model_ids(model_ids, _pinned),
|
"models": _merge_model_ids(model_ids, _pinned),
|
||||||
"pinned_models": _pinned,
|
"pinned_models": _pinned,
|
||||||
"online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")),
|
"online": is_online,
|
||||||
"status": "online" if (model_ids or _pinned) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")),
|
"status": is_status,
|
||||||
"ping_error": ping.get("error") if ping else None,
|
"ping_error": ping.get("error") if ping else None,
|
||||||
"endpoint_kind": requested_kind,
|
"endpoint_kind": requested_kind,
|
||||||
"category": _classify_endpoint(base_url, 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 = resolve_url(base_url)
|
||||||
base_url = _rewrite_loopback_for_docker(base_url)
|
base_url = _rewrite_loopback_for_docker(base_url)
|
||||||
requested_kind = _normalize_endpoint_kind(endpoint_kind)
|
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)
|
configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
||||||
probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout)
|
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)
|
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))
|
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 {
|
return {
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"online": bool(models) or bool(ping.get("reachable")),
|
"online": is_online,
|
||||||
"status": "online" if models else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")),
|
"status": is_status,
|
||||||
"ping_error": ping.get("error") if ping else None,
|
"ping_error": ping.get("error") if ping else None,
|
||||||
"models": models,
|
"models": models,
|
||||||
"count": len(models),
|
"count": len(models),
|
||||||
@@ -2531,6 +2584,149 @@ def setup_model_routes(model_discovery):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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")
|
@router.get("/default-chat")
|
||||||
def get_default_chat(request: Request):
|
def get_default_chat(request: Request):
|
||||||
# SECURITY: resolve the default endpoint + model from the CALLER's
|
# SECURITY: resolve the default endpoint + model from the CALLER's
|
||||||
@@ -2844,4 +3040,6 @@ def setup_model_routes(model_discovery):
|
|||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
return {"ok": True, "disabled": body.disabled}
|
return {"ok": True, "disabled": body.disabled}
|
||||||
|
|
||||||
|
router._should_refresh_endpoint = _should_refresh_endpoint
|
||||||
|
router._search_endpoint_catalog = search_endpoint_catalog
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -1099,6 +1099,8 @@ def _detect_provider(url: str) -> str:
|
|||||||
from src.copilot import is_copilot_base
|
from src.copilot import is_copilot_base
|
||||||
if is_copilot_base(url):
|
if is_copilot_base(url):
|
||||||
return "copilot"
|
return "copilot"
|
||||||
|
if _host_match(url, "featherless.ai"):
|
||||||
|
return "featherless"
|
||||||
if _host_match(url, "cerebras.ai"):
|
if _host_match(url, "cerebras.ai"):
|
||||||
return "cerebras"
|
return "cerebras"
|
||||||
if _host_match(url, "mistral.ai"):
|
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"
|
if is_chatgpt_subscription_base(url): return "ChatGPT Subscription"
|
||||||
from src.copilot import is_copilot_base
|
from src.copilot import is_copilot_base
|
||||||
if is_copilot_base(url): return "GitHub Copilot"
|
if is_copilot_base(url): return "GitHub Copilot"
|
||||||
|
if _host_match(url, "featherless.ai"): return "Featherless.ai"
|
||||||
if _host_match(url, "cerebras.ai"):
|
if _host_match(url, "cerebras.ai"):
|
||||||
return "cerebras"
|
return "cerebras"
|
||||||
if _host_match(url, "mistral.ai"): return "Mistral"
|
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="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="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="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://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
||||||
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</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>
|
<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>`;
|
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.
|
// ChatGPT per-endpoint usage panel expanded state persistence.
|
||||||
// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels.
|
// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels.
|
||||||
const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded';
|
const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded';
|
||||||
@@ -737,7 +1029,7 @@ async function loadEndpoints() {
|
|||||||
const statusBadge = ep.status === 'empty'
|
const statusBadge = ep.status === 'empty'
|
||||||
? '<span class="admin-badge">no models</span>'
|
? '<span class="admin-badge">no models</span>'
|
||||||
: ep.online
|
: 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>';
|
: '<span class="admin-badge admin-badge-off">offline</span>';
|
||||||
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
|
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
|
||||||
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
|
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
|
// Don't let interactions inside the expanded panel re-fire the
|
||||||
// expand/collapse handler — the search box was getting closed
|
// expand/collapse handler — the search box was getting closed
|
||||||
// because clicking it bubbled up to here.
|
// 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 epId = header.dataset.admEpHeader;
|
||||||
const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`);
|
const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`);
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
@@ -974,6 +1266,11 @@ async function loadEndpoints() {
|
|||||||
}
|
}
|
||||||
if (!_modelsLoaded && isOpen) {
|
if (!_modelsLoaded && isOpen) {
|
||||||
_modelsLoaded = true;
|
_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).
|
// Our shared whirlpool spinner (consistent with the rest of the app).
|
||||||
panel.innerHTML = '';
|
panel.innerHTML = '';
|
||||||
let _modelsSpin = null;
|
let _modelsSpin = null;
|
||||||
@@ -1509,6 +1806,12 @@ function initEndpointForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _renderEndpointTestResult(msg, res, d) {
|
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') {
|
if (res.ok && d.status === 'empty') {
|
||||||
msg.textContent = 'Online — no models found';
|
msg.textContent = 'Online — no models found';
|
||||||
msg.className = 'admin-success';
|
msg.className = 'admin-success';
|
||||||
@@ -1630,9 +1933,13 @@ function initEndpointForm() {
|
|||||||
await loadEndpoints();
|
await loadEndpoints();
|
||||||
await _selectAddedModelInChat(d);
|
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 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) {
|
if (!d.online) {
|
||||||
msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink;
|
msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink;
|
||||||
msg.className = 'admin-error';
|
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') {
|
} else if (d.status === 'empty') {
|
||||||
msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink;
|
msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink;
|
||||||
msg.className = 'admin-success';
|
msg.className = 'admin-success';
|
||||||
@@ -3809,7 +4116,7 @@ export function close() {
|
|||||||
settingsModule.close();
|
settingsModule.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml };
|
export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml, renderFeatherlessPanel };
|
||||||
|
|
||||||
const adminModule = { open, close, _initData, get _initialized() { return initialized; } };
|
const adminModule = { open, close, _initData, get _initialized() { return initialized; } };
|
||||||
export default adminModule;
|
export default adminModule;
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ const _PROVIDERS = [
|
|||||||
// NVIDIA / Nemotron (official Simple Icons)
|
// NVIDIA / Nemotron (official Simple Icons)
|
||||||
[/nvidia|nemotron/i,
|
[/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>'],
|
'<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
|
// 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 = [
|
const _ENDPOINT_LABELS = [
|
||||||
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
||||||
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
|
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
|
||||||
|
[/(^|\.)featherless\.ai$/i, "Featherless.ai"],
|
||||||
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
||||||
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
||||||
[/(^|\.)openai\.com$/i, "OpenAI"],
|
[/(^|\.)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