feat(ai): add OpenRouter and Ollama Cloud providers (#231)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
@@ -188,7 +188,7 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
||||||
"""
|
"""
|
||||||
import requests as _req
|
import requests as _req
|
||||||
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||||
|
|
||||||
current_url = sess.endpoint_url or ""
|
current_url = sess.endpoint_url or ""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -205,15 +205,19 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
if current_url and base in current_url:
|
if current_url and base in current_url:
|
||||||
continue
|
continue
|
||||||
# Quick ping
|
# Quick ping
|
||||||
ping_url = base + "/models"
|
ping_url = build_models_url(base)
|
||||||
headers = {}
|
headers = build_headers(ep.api_key, base)
|
||||||
if ep.api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {ep.api_key}"
|
|
||||||
try:
|
try:
|
||||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not models:
|
||||||
|
models = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
if not models:
|
if not models:
|
||||||
continue
|
continue
|
||||||
# Found a working endpoint — update session
|
# Found a working endpoint — update session
|
||||||
|
|||||||
@@ -62,14 +62,16 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from core.database import ModelEndpoint
|
from core.database import ModelEndpoint
|
||||||
|
from src.endpoint_resolver import build_headers, normalize_base
|
||||||
# Find matching endpoint by URL
|
# Find matching endpoint by URL
|
||||||
|
base = normalize_base(endpoint)
|
||||||
ep = db.query(ModelEndpoint).filter(
|
ep = db.query(ModelEndpoint).filter(
|
||||||
ModelEndpoint.base_url == endpoint.replace('/chat/completions', '')
|
ModelEndpoint.base_url == base
|
||||||
).first()
|
).first()
|
||||||
if ep and ep.api_key:
|
if ep and ep.api_key:
|
||||||
s = session_manager.sessions.get(sid)
|
s = session_manager.sessions.get(sid)
|
||||||
if s:
|
if s:
|
||||||
s.headers = {"Authorization": f"Bearer {ep.api_key}"}
|
s.headers = build_headers(ep.api_key, ep.base_url)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,60 @@ from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
|||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
||||||
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
||||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, _anthropic_api_root
|
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_api_root(base: str) -> str:
|
||||||
|
"""Return Anthropic's API root without duplicating /v1."""
|
||||||
|
base = (base or "").strip().rstrip("/")
|
||||||
|
host = urlparse(base).hostname or ""
|
||||||
|
if host.endswith("anthropic.com") and base.endswith("/v1"):
|
||||||
|
return base[:-3].rstrip("/")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_api_root(base: str) -> str:
|
||||||
|
"""Return Ollama's native API root without depending on deferred imports."""
|
||||||
|
base = (base or "").strip().rstrip("/")
|
||||||
|
parsed = urlparse(base)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if path.endswith("/api"):
|
||||||
|
return base
|
||||||
|
if host.endswith("ollama.com"):
|
||||||
|
root = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "https://ollama.com"
|
||||||
|
return root.rstrip("/") + "/api"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _models_url(base: str) -> str:
|
||||||
|
"""Return provider-specific model-list URL for route-local probing."""
|
||||||
|
provider = _detect_provider(base)
|
||||||
|
host = urlparse(base).hostname or ""
|
||||||
|
if provider == "anthropic" or host.endswith("anthropic.com"):
|
||||||
|
return _anthropic_api_root(base) + "/v1/models"
|
||||||
|
if provider == "ollama" or host.endswith("ollama.com"):
|
||||||
|
return _ollama_api_root(base) + "/tags"
|
||||||
|
return base.rstrip("/") + "/models"
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||||
|
"""Build provider auth headers without depending on import-time stubs."""
|
||||||
|
if not api_key:
|
||||||
|
return {}
|
||||||
|
provider = _detect_provider(base)
|
||||||
|
host = urlparse(base).hostname or ""
|
||||||
|
if provider == "anthropic" or host.endswith("anthropic.com"):
|
||||||
|
return {
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}
|
||||||
|
return {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
|
||||||
# ── Curated model lists per provider ──
|
# ── Curated model lists per provider ──
|
||||||
# For cloud providers that return 100+ models, only show these by default.
|
# For cloud providers that return 100+ models, only show these by default.
|
||||||
# A model ID matches if it starts with or equals a curated entry.
|
# A model ID matches if it starts with or equals a curated entry.
|
||||||
@@ -87,6 +135,7 @@ _URL_TO_CURATED = {
|
|||||||
"generativelanguage.googleapis.com": "google",
|
"generativelanguage.googleapis.com": "google",
|
||||||
"api.x.ai": "xai",
|
"api.x.ai": "xai",
|
||||||
"openrouter.ai": "openrouter",
|
"openrouter.ai": "openrouter",
|
||||||
|
"ollama.com": "ollama",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -183,9 +232,15 @@ def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 1
|
|||||||
payload = _build_anthropic_payload(model_id, messages, 0.0, 5)
|
payload = _build_anthropic_payload(model_id, messages, 0.0, 5)
|
||||||
if _test_tools:
|
if _test_tools:
|
||||||
payload["tools"] = [{"name": "test", "description": "Test tool", "input_schema": {"type": "object", "properties": {}}}]
|
payload["tools"] = [{"name": "test", "description": "Test tool", "input_schema": {"type": "object", "properties": {}}}]
|
||||||
|
elif provider == "ollama":
|
||||||
|
from src.llm_core import _build_ollama_payload
|
||||||
|
target_url = build_chat_url(base)
|
||||||
|
h = _provider_headers(api_key, base)
|
||||||
|
h["Content-Type"] = "application/json"
|
||||||
|
payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools)
|
||||||
else:
|
else:
|
||||||
target_url = build_chat_url(base)
|
target_url = build_chat_url(base)
|
||||||
h = build_headers(api_key, base)
|
h = _provider_headers(api_key, base)
|
||||||
h["Content-Type"] = "application/json"
|
h["Content-Type"] = "application/json"
|
||||||
from src.llm_core import _uses_max_completion_tokens
|
from src.llm_core import _uses_max_completion_tokens
|
||||||
_max_key = "max_completion_tokens" if _uses_max_completion_tokens(model_id) else "max_tokens"
|
_max_key = "max_completion_tokens" if _uses_max_completion_tokens(model_id) else "max_tokens"
|
||||||
@@ -276,10 +331,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
return []
|
return []
|
||||||
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
|
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
|
||||||
return list(ANTHROPIC_MODELS)
|
return list(ANTHROPIC_MODELS)
|
||||||
url = base + "/models"
|
url = _models_url(base)
|
||||||
headers = {}
|
headers = _provider_headers(api_key, base)
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout)
|
r = httpx.get(url, headers=headers, timeout=timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -494,10 +547,7 @@ def setup_model_routes(model_discovery):
|
|||||||
pass
|
pass
|
||||||
model_ids = [m for m in model_ids if m not in hidden]
|
model_ids = [m for m in model_ids if m not in hidden]
|
||||||
# Build correct URL based on provider
|
# Build correct URL based on provider
|
||||||
if provider == "anthropic":
|
chat_url = build_chat_url(base)
|
||||||
chat_url = build_chat_url(base)
|
|
||||||
else:
|
|
||||||
chat_url = base + "/chat/completions"
|
|
||||||
category = _classify_endpoint(base)
|
category = _classify_endpoint(base)
|
||||||
|
|
||||||
if model_ids:
|
if model_ids:
|
||||||
@@ -671,10 +721,8 @@ def setup_model_routes(model_discovery):
|
|||||||
entry["error"] = str(e)
|
entry["error"] = str(e)
|
||||||
entry["model_count"] = 0
|
entry["model_count"] = 0
|
||||||
else:
|
else:
|
||||||
url = base + "/models"
|
url = _models_url(base)
|
||||||
headers = {}
|
headers = _provider_headers(ep.api_key, base)
|
||||||
if ep.api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {ep.api_key}"
|
|
||||||
try:
|
try:
|
||||||
t0 = _time.time()
|
t0 = _time.time()
|
||||||
r = httpx.get(url, headers=headers, timeout=5)
|
r = httpx.get(url, headers=headers, timeout=5)
|
||||||
@@ -682,6 +730,12 @@ def setup_model_routes(model_discovery):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not models:
|
||||||
|
models = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
entry["status"] = "online"
|
entry["status"] = "online"
|
||||||
entry["model_count"] = len(models)
|
entry["model_count"] = len(models)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -896,6 +950,7 @@ def setup_model_routes(model_discovery):
|
|||||||
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
||||||
if base_url.endswith(suffix):
|
if base_url.endswith(suffix):
|
||||||
base_url = base_url[:-len(suffix)].rstrip("/")
|
base_url = base_url[:-len(suffix)].rstrip("/")
|
||||||
|
base_url = _normalize_base(base_url)
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise HTTPException(400, "Base URL is required")
|
raise HTTPException(400, "Base URL is required")
|
||||||
# Resolve hostname via Tailscale if DNS fails
|
# Resolve hostname via Tailscale if DNS fails
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
)
|
)
|
||||||
# Set auth headers for custom API-key endpoints
|
# Set auth headers for custom API-key endpoints
|
||||||
resolved_key = api_key.strip() if api_key else ""
|
resolved_key = api_key.strip() if api_key else ""
|
||||||
|
resolved_base = endpoint_url
|
||||||
if not resolved_key and endpoint_id and endpoint_id.strip():
|
if not resolved_key and endpoint_id and endpoint_id.strip():
|
||||||
from core.database import ModelEndpoint
|
from core.database import ModelEndpoint
|
||||||
_db = SessionLocal()
|
_db = SessionLocal()
|
||||||
@@ -234,10 +235,12 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
ep = _db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id.strip()).first()
|
ep = _db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id.strip()).first()
|
||||||
if ep and ep.api_key:
|
if ep and ep.api_key:
|
||||||
resolved_key = ep.api_key
|
resolved_key = ep.api_key
|
||||||
|
resolved_base = ep.base_url
|
||||||
finally:
|
finally:
|
||||||
_db.close()
|
_db.close()
|
||||||
if resolved_key:
|
if resolved_key:
|
||||||
session.headers = {"Authorization": f"Bearer {resolved_key}"}
|
from src.endpoint_resolver import build_headers
|
||||||
|
session.headers = build_headers(resolved_key, resolved_base)
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
# Fire webhook (sync-safe)
|
# Fire webhook (sync-safe)
|
||||||
if webhook_manager:
|
if webhook_manager:
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ def setup_webhook_routes(
|
|||||||
"groq": "https://api.groq.com/openai/v1",
|
"groq": "https://api.groq.com/openai/v1",
|
||||||
"together": "https://api.together.xyz/v1",
|
"together": "https://api.together.xyz/v1",
|
||||||
"openrouter": "https://openrouter.ai/api/v1",
|
"openrouter": "https://openrouter.ai/api/v1",
|
||||||
|
"ollama": "https://ollama.com/api",
|
||||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +204,7 @@ def setup_webhook_routes(
|
|||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from core.database import ModelEndpoint
|
from core.database import ModelEndpoint
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||||
|
|
||||||
message = body.message.strip()
|
message = body.message.strip()
|
||||||
if not message:
|
if not message:
|
||||||
@@ -244,7 +246,8 @@ def setup_webhook_routes(
|
|||||||
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
||||||
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
||||||
|
|
||||||
endpoint_url = base_url + "/chat/completions"
|
base_url = normalize_base(base_url)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
|
||||||
if not session_manager:
|
if not session_manager:
|
||||||
raise HTTPException(500, "Session manager not available")
|
raise HTTPException(500, "Session manager not available")
|
||||||
@@ -254,7 +257,7 @@ def setup_webhook_routes(
|
|||||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||||
model=model, owner=token_owner,
|
model=model, owner=token_owner,
|
||||||
)
|
)
|
||||||
sess.headers = {"Authorization": f"Bearer {api_key}"}
|
sess.headers = build_headers(api_key, base_url)
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
session_id = sid
|
session_id = sid
|
||||||
|
|
||||||
@@ -271,18 +274,26 @@ def setup_webhook_routes(
|
|||||||
"No session, api_key, or configured endpoints. "
|
"No session, api_key, or configured endpoints. "
|
||||||
"Pass api_key + model, or configure an endpoint in Admin.")
|
"Pass api_key + model, or configure an endpoint in Admin.")
|
||||||
|
|
||||||
endpoint_url = ep.base_url.rstrip("/") + "/chat/completions"
|
base_url = normalize_base(ep.base_url)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
model = body.model or "auto"
|
model = body.model or "auto"
|
||||||
api_key = ep.api_key
|
api_key = ep.api_key
|
||||||
|
|
||||||
if model == "auto":
|
if model == "auto":
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
models_url = ep.base_url.rstrip("/") + "/models"
|
models_url = build_models_url(base_url)
|
||||||
hdrs = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
hdrs = build_headers(api_key, base_url)
|
||||||
resp = await client.get(models_url, headers=hdrs)
|
resp = await client.get(models_url, headers=hdrs)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
ids = [m.get("id") for m in (resp.json().get("data") or []) if m.get("id")]
|
data = resp.json()
|
||||||
|
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not ids:
|
||||||
|
ids = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
model = ids[0] if ids else "auto"
|
model = ids[0] if ids else "auto"
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(500, "Could not discover models from endpoint")
|
raise HTTPException(500, "Could not discover models from endpoint")
|
||||||
@@ -296,7 +307,7 @@ def setup_webhook_routes(
|
|||||||
model=model, owner=token_owner,
|
model=model, owner=token_owner,
|
||||||
)
|
)
|
||||||
if api_key:
|
if api_key:
|
||||||
sess.headers = {"Authorization": f"Bearer {api_key}"}
|
sess.headers = build_headers(api_key, base_url)
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
session_id = sid
|
session_id = sid
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ _API_HOSTS = frozenset([
|
|||||||
"api.deepseek.com", "deepseek.com",
|
"api.deepseek.com", "deepseek.com",
|
||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
|
"ollama.com",
|
||||||
])
|
])
|
||||||
_MCP_KEYWORDS = frozenset(["browse", "browser", "website", "calendar", "event", "email",
|
_MCP_KEYWORDS = frozenset(["browse", "browser", "website", "calendar", "event", "email",
|
||||||
"gmail", "screenshot", "navigate", "click", "miniflux", "rss", "feed"])
|
"gmail", "screenshot", "navigate", "click", "miniflux", "rss", "feed"])
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
|
|||||||
# Model resolution
|
# Model resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from src.endpoint_resolver import normalize_base as _normalize_base
|
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, build_models_url
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
||||||
@@ -95,9 +95,7 @@ def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
|||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
headers = {}
|
headers = build_headers(ep.api_key, base)
|
||||||
if ep.api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {ep.api_key}"
|
|
||||||
|
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
# Anthropic: match against hardcoded model list
|
# Anthropic: match against hardcoded model list
|
||||||
@@ -107,27 +105,32 @@ def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
|||||||
matched = am
|
matched = am
|
||||||
break
|
break
|
||||||
if matched:
|
if matched:
|
||||||
headers["x-api-key"] = ep.api_key or ""
|
return build_chat_url(base), matched, headers
|
||||||
headers["anthropic-version"] = "2023-06-01"
|
|
||||||
return base + "/v1/messages", matched, headers
|
|
||||||
else:
|
else:
|
||||||
# OpenAI-compatible: probe /models
|
# OpenAI-compatible and native Ollama: probe the provider's model list.
|
||||||
try:
|
try:
|
||||||
r = httpx.get(base + "/models", headers=headers, timeout=5)
|
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
model_ids = [m.get("id") for m in (r.json().get("data") or []) if m.get("id")]
|
data = r.json()
|
||||||
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not model_ids:
|
||||||
|
model_ids = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
except Exception:
|
except Exception:
|
||||||
model_ids = []
|
model_ids = []
|
||||||
|
|
||||||
# Exact match first
|
# Exact match first
|
||||||
for mid in model_ids:
|
for mid in model_ids:
|
||||||
if mid.lower() == model_name.lower():
|
if mid.lower() == model_name.lower():
|
||||||
return base + "/chat/completions", mid, headers
|
return build_chat_url(base), mid, headers
|
||||||
|
|
||||||
# Partial match
|
# Partial match
|
||||||
for mid in model_ids:
|
for mid in model_ids:
|
||||||
if model_name.lower() in mid.lower() or mid.lower() in model_name.lower():
|
if model_name.lower() in mid.lower() or mid.lower() in model_name.lower():
|
||||||
return base + "/chat/completions", mid, headers
|
return build_chat_url(base), mid, headers
|
||||||
|
|
||||||
raise ValueError(f"Model '{spec}' not found on any configured endpoint")
|
raise ValueError(f"Model '{spec}' not found on any configured endpoint")
|
||||||
finally:
|
finally:
|
||||||
@@ -1107,18 +1110,23 @@ async def do_list_models(content: str, session_id: Optional[str] = None) -> Dict
|
|||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
headers = {}
|
headers = build_headers(ep.api_key, base)
|
||||||
if ep.api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {ep.api_key}"
|
|
||||||
|
|
||||||
model_ids = []
|
model_ids = []
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
model_ids = list(ANTHROPIC_MODELS)
|
model_ids = list(ANTHROPIC_MODELS)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
r = httpx.get(base + "/models", headers=headers, timeout=5)
|
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
model_ids = [m.get("id") for m in (r.json().get("data") or []) if m.get("id")]
|
data = r.json()
|
||||||
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not model_ids:
|
||||||
|
model_ids = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
except Exception:
|
except Exception:
|
||||||
model_ids = ["(endpoint offline)"]
|
model_ids = ["(endpoint offline)"]
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ def normalize_base(url: str) -> str:
|
|||||||
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
||||||
if url.endswith(suffix):
|
if url.endswith(suffix):
|
||||||
url = url[: -len(suffix)].rstrip("/")
|
url = url[: -len(suffix)].rstrip("/")
|
||||||
|
for suffix in ["/chat", "/tags", "/generate"]:
|
||||||
|
if url.endswith("/api" + suffix):
|
||||||
|
url = url[: -len(suffix)].rstrip("/")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +116,20 @@ def _anthropic_api_root(base: str) -> str:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_api_root(base: str) -> str:
|
||||||
|
"""Return the native Ollama API root, adding /api for ollama.com hosts."""
|
||||||
|
base = (base or "").strip().rstrip("/")
|
||||||
|
parsed = urlparse(base)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if path.endswith("/api"):
|
||||||
|
return base
|
||||||
|
if host.endswith("ollama.com"):
|
||||||
|
root = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "https://ollama.com"
|
||||||
|
return root.rstrip("/") + "/api"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
def build_chat_url(base: str) -> str:
|
def build_chat_url(base: str) -> str:
|
||||||
"""Return the correct chat endpoint URL for a given base."""
|
"""Return the correct chat endpoint URL for a given base."""
|
||||||
base = resolve_url(base)
|
base = resolve_url(base)
|
||||||
@@ -120,9 +137,23 @@ def build_chat_url(base: str) -> str:
|
|||||||
host = urlparse(base).hostname or ""
|
host = urlparse(base).hostname or ""
|
||||||
if provider == "anthropic" or host.endswith("anthropic.com"):
|
if provider == "anthropic" or host.endswith("anthropic.com"):
|
||||||
return _anthropic_api_root(base) + "/v1/messages"
|
return _anthropic_api_root(base) + "/v1/messages"
|
||||||
|
if provider == "ollama" or host.endswith("ollama.com"):
|
||||||
|
return _ollama_api_root(base) + "/chat"
|
||||||
return base + "/chat/completions"
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def build_models_url(base: str) -> str:
|
||||||
|
"""Return the provider-specific model-list endpoint URL for a base."""
|
||||||
|
base = resolve_url(base)
|
||||||
|
provider = _detect_provider(base)
|
||||||
|
host = urlparse(base).hostname or ""
|
||||||
|
if provider == "anthropic" or host.endswith("anthropic.com"):
|
||||||
|
return _anthropic_api_root(base) + "/v1/models"
|
||||||
|
if provider == "ollama" or host.endswith("ollama.com"):
|
||||||
|
return _ollama_api_root(base) + "/tags"
|
||||||
|
return base + "/models"
|
||||||
|
|
||||||
|
|
||||||
def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||||
"""Build auth headers for an endpoint."""
|
"""Build auth headers for an endpoint."""
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
|
|||||||
171
src/llm_core.py
171
src/llm_core.py
@@ -7,6 +7,7 @@ import logging
|
|||||||
import hashlib
|
import hashlib
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -140,9 +141,82 @@ ANTHROPIC_MODELS = [
|
|||||||
"claude-haiku-4-20250514", "claude-haiku-4", "claude-haiku-3-5-20241022", "claude-haiku-3-5",
|
"claude-haiku-4-20250514", "claude-haiku-4", "claude-haiku-3-5-20241022", "claude-haiku-3-5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ollama_native_url(url: str) -> bool:
|
||||||
|
"""Return True for native Ollama API URLs, including Ollama Cloud."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if host.endswith("ollama.com"):
|
||||||
|
return True
|
||||||
|
local_ollama_host = host in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} or parsed.port == 11434
|
||||||
|
return local_ollama_host and (path == "/api" or path.startswith("/api/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_api_root(url: str) -> str:
|
||||||
|
"""Return a native Ollama API root such as https://ollama.com/api."""
|
||||||
|
url = (url or "").strip().rstrip("/")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if path.endswith("/api/chat"):
|
||||||
|
return url[: -len("/chat")]
|
||||||
|
if path.endswith("/api/tags"):
|
||||||
|
return url[: -len("/tags")]
|
||||||
|
if path.endswith("/api/generate"):
|
||||||
|
return url[: -len("/generate")]
|
||||||
|
if path.endswith("/api"):
|
||||||
|
return url
|
||||||
|
if host.endswith("ollama.com"):
|
||||||
|
root = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "https://ollama.com"
|
||||||
|
return root.rstrip("/") + "/api"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ollama_url(url: str) -> str:
|
||||||
|
"""Ensure a native Ollama URL points at /api/chat."""
|
||||||
|
base = _ollama_api_root(url)
|
||||||
|
return base.rstrip("/") + "/chat"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ollama_payload(
|
||||||
|
model: str,
|
||||||
|
messages: List[Dict],
|
||||||
|
temperature: float,
|
||||||
|
max_tokens: int,
|
||||||
|
stream: bool = False,
|
||||||
|
tools: Optional[List[Dict]] = None,
|
||||||
|
) -> Dict:
|
||||||
|
payload: Dict = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": stream,
|
||||||
|
}
|
||||||
|
options: Dict = {}
|
||||||
|
if temperature is not None:
|
||||||
|
options["temperature"] = temperature
|
||||||
|
if max_tokens and max_tokens > 0:
|
||||||
|
options["num_predict"] = max_tokens
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ollama_response(data: dict) -> str:
|
||||||
|
message = data.get("message") or {}
|
||||||
|
return message.get("content") or data.get("response") or ""
|
||||||
|
|
||||||
|
|
||||||
def _detect_provider(url: str) -> str:
|
def _detect_provider(url: str) -> str:
|
||||||
"""Detect API provider from URL."""
|
"""Detect API provider from URL."""
|
||||||
u = (url or "").lower()
|
u = (url or "").lower()
|
||||||
|
if _is_ollama_native_url(url):
|
||||||
|
return "ollama"
|
||||||
if "anthropic.com" in u:
|
if "anthropic.com" in u:
|
||||||
return "anthropic"
|
return "anthropic"
|
||||||
if "openrouter.ai" in u:
|
if "openrouter.ai" in u:
|
||||||
@@ -166,6 +240,7 @@ def _provider_label(url: str) -> str:
|
|||||||
"""Human-friendly provider name for error messages."""
|
"""Human-friendly provider name for error messages."""
|
||||||
u = (url or "").lower()
|
u = (url or "").lower()
|
||||||
if "anthropic.com" in u: return "Anthropic"
|
if "anthropic.com" in u: return "Anthropic"
|
||||||
|
if "ollama.com" in u: return "Ollama Cloud"
|
||||||
if "api.x.ai" in u or "x.ai/" in u: return "xAI"
|
if "api.x.ai" in u or "x.ai/" in u: return "xAI"
|
||||||
if "openai.com" in u: return "OpenAI"
|
if "openai.com" in u: return "OpenAI"
|
||||||
if "openrouter.ai" in u: return "OpenRouter"
|
if "openrouter.ai" in u: return "OpenRouter"
|
||||||
@@ -396,19 +471,28 @@ def _normalize_anthropic_url(url: str) -> str:
|
|||||||
|
|
||||||
def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, headers: Optional[Dict] = None) -> List[str]:
|
def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, headers: Optional[Dict] = None) -> List[str]:
|
||||||
"""List available model IDs from an endpoint."""
|
"""List available model IDs from an endpoint."""
|
||||||
if _detect_provider(base_chat_url) == "anthropic":
|
provider = _detect_provider(base_chat_url)
|
||||||
|
if provider == "anthropic":
|
||||||
return list(ANTHROPIC_MODELS)
|
return list(ANTHROPIC_MODELS)
|
||||||
try:
|
try:
|
||||||
h = {}
|
h = {}
|
||||||
if headers:
|
if headers:
|
||||||
h.update(headers)
|
h.update(headers)
|
||||||
r = httpx.get(base_chat_url.replace("/chat/completions", "/models"), headers=h, timeout=timeout)
|
if provider == "ollama":
|
||||||
|
models_url = _ollama_api_root(base_chat_url) + "/tags"
|
||||||
|
else:
|
||||||
|
models_url = base_chat_url.replace("/chat/completions", "/models")
|
||||||
|
r = httpx.get(models_url, headers=h, timeout=timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
if ids:
|
if not model_ids:
|
||||||
return ids
|
model_ids = [
|
||||||
return [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
return model_ids
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
if ":11434" in base_chat_url or "ollama" in base_chat_url.lower():
|
if ":11434" in base_chat_url or "ollama" in base_chat_url.lower():
|
||||||
@@ -476,6 +560,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
target_url = _normalize_anthropic_url(url)
|
target_url = _normalize_anthropic_url(url)
|
||||||
h = _build_anthropic_headers(headers)
|
h = _build_anthropic_headers(headers)
|
||||||
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens)
|
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens)
|
||||||
|
elif provider == "ollama":
|
||||||
|
target_url = _normalize_ollama_url(url)
|
||||||
|
payload = _build_ollama_payload(model, messages_copy, temperature, max_tokens, stream=False)
|
||||||
else:
|
else:
|
||||||
target_url = url
|
target_url = url
|
||||||
payload = {
|
payload = {
|
||||||
@@ -497,6 +584,8 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
try:
|
try:
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
response = _parse_anthropic_response(data)
|
response = _parse_anthropic_response(data)
|
||||||
|
elif provider == "ollama":
|
||||||
|
response = _parse_ollama_response(data)
|
||||||
else:
|
else:
|
||||||
response = data["choices"][0]["message"]["content"]
|
response = data["choices"][0]["message"]["content"]
|
||||||
_set_cached_response(cache_key, response)
|
_set_cached_response(cache_key, response)
|
||||||
@@ -583,6 +672,12 @@ async def llm_call_async(
|
|||||||
target_url = _normalize_anthropic_url(url)
|
target_url = _normalize_anthropic_url(url)
|
||||||
h = _build_anthropic_headers(headers)
|
h = _build_anthropic_headers(headers)
|
||||||
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens)
|
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens)
|
||||||
|
elif provider == "ollama":
|
||||||
|
target_url = _normalize_ollama_url(url)
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
payload = _build_ollama_payload(model, messages_copy, temperature, max_tokens, stream=False)
|
||||||
else:
|
else:
|
||||||
target_url = url
|
target_url = url
|
||||||
h = _provider_headers(provider, headers)
|
h = _provider_headers(provider, headers)
|
||||||
@@ -621,6 +716,8 @@ async def llm_call_async(
|
|||||||
try:
|
try:
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
response = _parse_anthropic_response(data)
|
response = _parse_anthropic_response(data)
|
||||||
|
elif provider == "ollama":
|
||||||
|
response = _parse_ollama_response(data)
|
||||||
else:
|
else:
|
||||||
response = data["choices"][0]["message"]["content"]
|
response = data["choices"][0]["message"]["content"]
|
||||||
_set_cached_response(cache_key, response)
|
_set_cached_response(cache_key, response)
|
||||||
@@ -673,6 +770,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
target_url = _normalize_anthropic_url(url)
|
target_url = _normalize_anthropic_url(url)
|
||||||
h = _build_anthropic_headers(headers)
|
h = _build_anthropic_headers(headers)
|
||||||
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens, stream=True, tools=tools)
|
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens, stream=True, tools=tools)
|
||||||
|
elif provider == "ollama":
|
||||||
|
target_url = _normalize_ollama_url(url)
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
payload = _build_ollama_payload(model, messages_copy, temperature, max_tokens, stream=True, tools=tools)
|
||||||
else:
|
else:
|
||||||
target_url = url
|
target_url = url
|
||||||
payload = {
|
payload = {
|
||||||
@@ -699,6 +802,62 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
return
|
return
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
|
|
||||||
|
# ── Native Ollama streaming ──
|
||||||
|
if provider == "ollama":
|
||||||
|
_ollama_tool_calls: List[Dict] = []
|
||||||
|
try:
|
||||||
|
client = _get_http_client()
|
||||||
|
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
||||||
|
_clear_host_dead(target_url)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raw = (await r.aread()).decode(errors="replace")
|
||||||
|
friendly = _format_upstream_error(r.status_code, raw, target_url)
|
||||||
|
yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n'
|
||||||
|
return
|
||||||
|
async for line in r.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
j = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
message = j.get("message") or {}
|
||||||
|
thinking = message.get("thinking") or ""
|
||||||
|
if thinking:
|
||||||
|
yield f'data: {json.dumps({"delta": thinking, "thinking": True})}\n\n'
|
||||||
|
content = message.get("content") or ""
|
||||||
|
if content:
|
||||||
|
yield f'data: {json.dumps({"delta": content})}\n\n'
|
||||||
|
for tc in message.get("tool_calls") or []:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
if fn.get("name"):
|
||||||
|
_ollama_tool_calls.append({
|
||||||
|
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
|
||||||
|
"name": fn.get("name") or "",
|
||||||
|
"arguments": json.dumps(fn.get("arguments") or {}),
|
||||||
|
})
|
||||||
|
if j.get("done"):
|
||||||
|
if _ollama_tool_calls:
|
||||||
|
yield f'data: {json.dumps({"type": "tool_calls", "calls": _ollama_tool_calls})}\n\n'
|
||||||
|
if j.get("prompt_eval_count") is not None or j.get("eval_count") is not None:
|
||||||
|
yield f'data: {json.dumps({"type": "usage", "data": {"input_tokens": j.get("prompt_eval_count", 0), "output_tokens": j.get("eval_count", 0)}})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
return
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
||||||
|
_cooled = _mark_host_dead(target_url)
|
||||||
|
_tail = f" — host cooled for {DEAD_HOST_COOLDOWN:.0f}s" if _cooled else " — transient, will retry"
|
||||||
|
logger.warning(f"Ollama stream connect to {target_url} failed: {e}{_tail}")
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": f"Cannot reach {_host_key(target_url)}", "status": 503})}\n\n'
|
||||||
|
except httpx.ReadTimeout:
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": "Read timeout", "status": 504})}\n\n'
|
||||||
|
except httpx.NetworkError:
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502})}\n\n'
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ollama stream error: {e}")
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502})}\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
# ── Anthropic streaming ──
|
# ── Anthropic streaming ──
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
_anth_input_tokens = 0
|
_anth_input_tokens = 0
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ _SOTA_HOSTS = frozenset({
|
|||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
"generativelanguage.googleapis.com", "api.groq.com",
|
"generativelanguage.googleapis.com", "api.groq.com",
|
||||||
|
"openrouter.ai", "ollama.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2036,6 +2036,7 @@
|
|||||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
||||||
<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="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://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
||||||
<option value="https://api.mistral.ai/v1" data-logo="mistral">Mistral</option>
|
<option value="https://api.mistral.ai/v1" data-logo="mistral">Mistral</option>
|
||||||
<option value="https://api.together.xyz/v1" data-logo="together">Together AI</option>
|
<option value="https://api.together.xyz/v1" data-logo="together">Together AI</option>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
|
import { sortModelObjects } from './modelSort.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
@@ -216,7 +217,7 @@ async function _loadModelsForUser(username, allowedSet, privPanel) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allEmpty = allowedSet.size === 0;
|
const allEmpty = allowedSet.size === 0;
|
||||||
listEl.innerHTML = allModels.map(m => {
|
listEl.innerHTML = sortModelObjects(allModels).map(m => {
|
||||||
const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : '';
|
const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : '';
|
||||||
return `<label>
|
return `<label>
|
||||||
<input type="checkbox" class="priv-model-cb" data-mid="${esc(m.mid)}" ${checked}>
|
<input type="checkbox" class="priv-model-cb" data-mid="${esc(m.mid)}" ${checked}>
|
||||||
@@ -377,6 +378,9 @@ async function loadEndpoints() {
|
|||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||||
|
settingsModule.refreshAiModelEndpoints();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||||
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
|
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
|
||||||
@@ -552,17 +556,18 @@ async function loadEndpoints() {
|
|||||||
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
||||||
const models = await res.json();
|
const models = await res.json();
|
||||||
_stopSpin();
|
_stopSpin();
|
||||||
if (!models.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
|
const sortedModels = sortModelObjects(models);
|
||||||
const hiddenSet = new Set(models.filter(m => m.is_hidden).map(m => m.id));
|
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
|
||||||
const showSearch = models.length >= 8;
|
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||||||
|
const showSearch = sortedModels.length >= 8;
|
||||||
panel.innerHTML = `<div class="mcp-tools-header">
|
panel.innerHTML = `<div class="mcp-tools-header">
|
||||||
<span>Models</span>
|
<span>Models</span>
|
||||||
<span style="display:flex;gap:8px;align-items:center;">
|
<span style="display:flex;gap:8px;align-items:center;">
|
||||||
<span class="mcp-tools-count">${models.length - hiddenSet.size}/${models.length} enabled</span>
|
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
||||||
<a href="#" data-ep-select-all="${epId}">All</a>
|
<a href="#" data-ep-select-all="${epId}">All</a>
|
||||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||||
</span>
|
</span>
|
||||||
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${models.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + models.map(m =>
|
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||||||
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
||||||
<span class="adm-check-dot" aria-hidden="true"></span>
|
<span class="adm-check-dot" aria-hidden="true"></span>
|
||||||
@@ -623,6 +628,9 @@ async function _saveEpModelState(epId, panel) {
|
|||||||
const badge = row.querySelector('.admin-badge');
|
const badge = row.querySelector('.admin-badge');
|
||||||
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
|
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
|
||||||
}
|
}
|
||||||
|
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||||
|
settingsModule.refreshAiModelEndpoints();
|
||||||
|
}
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,12 +710,19 @@ function initEndpointForm() {
|
|||||||
// Strip trailing paths that shouldn't be in a base URL
|
// Strip trailing paths that shouldn't be in a base URL
|
||||||
u = u.replace(/\/v1\/(models|chat\/completions|completions|messages)\/?$/i, '/v1');
|
u = u.replace(/\/v1\/(models|chat\/completions|completions|messages)\/?$/i, '/v1');
|
||||||
u = u.replace(/\/(models|chat\/completions|completions|v1\/messages)\/?$/i, '');
|
u = u.replace(/\/(models|chat\/completions|completions|v1\/messages)\/?$/i, '');
|
||||||
|
u = u.replace(/\/api\/(chat|tags|generate)\/?$/i, '/api');
|
||||||
// Fix double /v1/v1
|
// Fix double /v1/v1
|
||||||
u = u.replace(/\/v1\/v1$/, '/v1');
|
u = u.replace(/\/v1\/v1$/, '/v1');
|
||||||
// Strip query params and fragments
|
// Strip query params and fragments
|
||||||
u = u.split('?')[0].split('#')[0];
|
u = u.split('?')[0].split('#')[0];
|
||||||
|
try {
|
||||||
|
const parsed = new URL(u);
|
||||||
|
if (parsed.hostname.endsWith('ollama.com')) {
|
||||||
|
u = 'https://ollama.com/api';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
|
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
|
||||||
if (!u.includes('api.') && !u.includes('openrouter') && !u.endsWith('/v1')) {
|
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(u);
|
const parsed = new URL(u);
|
||||||
if (!parsed.pathname || parsed.pathname === '/') {
|
if (!parsed.pathname || parsed.pathname === '/') {
|
||||||
@@ -814,9 +829,13 @@ function initEndpointForm() {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', url);
|
fd.append('base_url', url);
|
||||||
if (apiKey) fd.append('api_key', apiKey);
|
if (apiKey) fd.append('api_key', apiKey);
|
||||||
|
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
||||||
|
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
||||||
|
}
|
||||||
const epType = el('adm-epType');
|
const epType = el('adm-epType');
|
||||||
if (epType) fd.append('model_type', epType.value);
|
if (epType) fd.append('model_type', epType.value);
|
||||||
fd.append('skip_probe', 'false');
|
if (provider.value && /openrouter\.ai|ollama\.com/i.test(provider.value)) fd.append('require_models', 'true');
|
||||||
|
else fd.append('skip_probe', 'false');
|
||||||
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import { selectSession } from './sessions.js';
|
import { selectSession } from './sessions.js';
|
||||||
|
import { sortModelIds } from './modelSort.js';
|
||||||
|
|
||||||
const API = '/api/assistant';
|
const API = '/api/assistant';
|
||||||
|
|
||||||
@@ -250,9 +251,8 @@ function _renderSettingsBody(body, data, tzList) {
|
|||||||
try {
|
try {
|
||||||
const models = await _fetchJSON(`/api/model-endpoints/${ep.id}/models`);
|
const models = await _fetchJSON(`/api/model-endpoints/${ep.id}/models`);
|
||||||
let mHTML = '';
|
let mHTML = '';
|
||||||
for (const m of (models.models || models || [])) {
|
const modelIds = (models.models || models || []).map(m => typeof m === 'string' ? m : (m.id || m.name || '')).filter(Boolean);
|
||||||
const mid = typeof m === 'string' ? m : (m.id || m.name || '');
|
for (const mid of sortModelIds(modelIds)) {
|
||||||
if (!mid) continue;
|
|
||||||
const sel = mid === crew.model ? ' selected' : '';
|
const sel = mid === crew.model ? ' selected' : '';
|
||||||
mHTML += `<option value="${_esc(mid)}"${sel}>${_esc(mid.split('/').pop())}</option>`;
|
mHTML += `<option value="${_esc(mid)}"${sel}>${_esc(mid.split('/').pop())}</option>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Storage from '../storage.js';
|
import Storage from '../storage.js';
|
||||||
import state from './state.js';
|
import state from './state.js';
|
||||||
import uiModule from '../ui.js';
|
import uiModule from '../ui.js';
|
||||||
|
import { sortModelObjects } from '../modelSort.js';
|
||||||
|
|
||||||
var escapeHtml = uiModule.esc;
|
var escapeHtml = uiModule.esc;
|
||||||
|
|
||||||
@@ -84,9 +85,9 @@ async function fetchModels() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
state._fetchModelsCache = models;
|
state._fetchModelsCache = sortModelObjects(models);
|
||||||
state._fetchModelsCacheTime = now;
|
state._fetchModelsCacheTime = now;
|
||||||
return models;
|
return state._fetchModelsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shuffle pool persistence ──
|
// ── Shuffle pool persistence ──
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
* }} deps
|
* }} deps
|
||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
|
import { sortModelIds } from '../modelSort.js';
|
||||||
|
|
||||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||||
// - gen: text-to-image generation
|
// - gen: text-to-image generation
|
||||||
@@ -106,7 +107,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
for (const ep of endpoints) {
|
for (const ep of endpoints) {
|
||||||
if (!ep.is_enabled) continue;
|
if (!ep.is_enabled) continue;
|
||||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||||
const models = hasListedModels ? ep.models : [''];
|
const models = hasListedModels ? sortModelIds(ep.models) : [''];
|
||||||
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
||||||
// Image/inpaint endpoints can be called by URL even when their
|
// Image/inpaint endpoints can be called by URL even when their
|
||||||
// /models cache is still empty, so don't strand a freshly served
|
// /models cache is still empty, so don't strand a freshly served
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import chatRenderer from './chatRenderer.js';
|
|||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
|
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
|
||||||
|
import { sortModelObjects } from './modelSort.js';
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let _active = false;
|
let _active = false;
|
||||||
@@ -55,7 +56,7 @@ function _initGroupTab() {
|
|||||||
result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id });
|
result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
_modelsCache = result;
|
_modelsCache = sortModelObjects(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +413,7 @@ export async function showModelPicker() {
|
|||||||
result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id, epName: item.endpoint_name || '' });
|
result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id, epName: item.endpoint_name || '' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
_cachedModels = result;
|
_cachedModels = sortModelObjects(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
|
import { sortModelObjects } from './modelSort.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ function _initModelPickerDropdown() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return result;
|
return sortModelObjects(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _populate(filter) {
|
function _populate(filter) {
|
||||||
@@ -184,6 +185,8 @@ function _initModelPickerDropdown() {
|
|||||||
if (favs.includes(m.mid)) favModels.push(m);
|
if (favs.includes(m.mid)) favModels.push(m);
|
||||||
else restModels.push(m);
|
else restModels.push(m);
|
||||||
});
|
});
|
||||||
|
sortModelObjects(favModels).forEach(function(m, i) { favModels[i] = m; });
|
||||||
|
sortModelObjects(restModels).forEach(function(m, i) { restModels[i] = m; });
|
||||||
|
|
||||||
function _addSection(label) {
|
function _addSection(label) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
|
|||||||
29
static/js/modelSort.js
Normal file
29
static/js/modelSort.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Shared alphabetical sorting for model pickers and dropdowns.
|
||||||
|
|
||||||
|
function _sortText(value) {
|
||||||
|
return String(value || '').split('/').pop().trim() || String(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _compareText(a, b) {
|
||||||
|
return _sortText(a).localeCompare(_sortText(b), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
}) || String(a || '').localeCompare(String(b || ''), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortModelIds(models) {
|
||||||
|
return (models || []).slice().sort(_compareText);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareModelObjects(a, b) {
|
||||||
|
const aLabel = a && (a.display || a.displayName || a.name || a.mid || a.id || a.model);
|
||||||
|
const bLabel = b && (b.display || b.displayName || b.name || b.mid || b.id || b.model);
|
||||||
|
return _compareText(aLabel, bLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortModelObjects(models) {
|
||||||
|
return (models || []).slice().sort(compareModelObjects);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import dragSortModule from './dragSort.js';
|
|||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { modelColor } from './chatRenderer.js';
|
import { modelColor } from './chatRenderer.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
|
import { sortModelIds } from './modelSort.js';
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
||||||
@@ -603,7 +604,7 @@ export async function refreshProviders() {
|
|||||||
|
|
||||||
if (openai) {
|
if (openai) {
|
||||||
const models = (openai.items?.[0]?.models) || [];
|
const models = (openai.items?.[0]?.models) || [];
|
||||||
models.forEach(m => {
|
sortModelIds(models).forEach(m => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = m;
|
opt.value = m;
|
||||||
opt.textContent = m;
|
opt.textContent = m;
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ const _PROVIDERS = [
|
|||||||
[/openai|gpt-|^o[13]-|chatgpt|dall-e/i,
|
[/openai|gpt-|^o[13]-|chatgpt|dall-e/i,
|
||||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>'],
|
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>'],
|
||||||
|
|
||||||
|
// OpenRouter
|
||||||
|
[/openrouter|open router/i,
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
|
||||||
|
|
||||||
|
// Ollama / Ollama Cloud
|
||||||
|
[/ollama/i,
|
||||||
|
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.4 10.2a4.8 4.8 0 0 1 9.1-1.9 4.1 4.1 0 0 1 1 .1A4.8 4.8 0 0 1 17 18H7.4a3.9 3.9 0 0 1 0-7.8Zm0 2a1.9 1.9 0 0 0 0 3.8H17a2.8 2.8 0 0 0 .2-5.6 2.7 2.7 0 0 0-1.3.2l-.9.4-.4-.9a2.8 2.8 0 0 0-5.4 1.1v1H7.4Z"/></svg>'],
|
||||||
|
|
||||||
// Anthropic — Claude (official Simple Icons)
|
// Anthropic — Claude (official Simple Icons)
|
||||||
[/anthropic|claude/i,
|
[/anthropic|claude/i,
|
||||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>'],
|
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>'],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as jobs from './jobs.js';
|
|||||||
import themeModule from '../theme.js';
|
import themeModule from '../theme.js';
|
||||||
import createResearchSynapse from '../researchSynapse.js';
|
import createResearchSynapse from '../researchSynapse.js';
|
||||||
import spinnerModule from '../spinner.js';
|
import spinnerModule from '../spinner.js';
|
||||||
|
import { sortModelIds } from '../modelSort.js';
|
||||||
|
|
||||||
// jobId -> { synapse, status } — survives across _renderJobs() rebuilds so
|
// jobId -> { synapse, status } — survives across _renderJobs() rebuilds so
|
||||||
// the SVG keeps its accumulated nodes/edges between progress events.
|
// the SVG keeps its accumulated nodes/edges between progress events.
|
||||||
@@ -637,7 +638,7 @@ function _populateModels(endpointId) {
|
|||||||
if (!endpointId) return;
|
if (!endpointId) return;
|
||||||
const ep = _endpoints.find(e => e.id === endpointId);
|
const ep = _endpoints.find(e => e.id === endpointId);
|
||||||
if (!ep || !ep.models) return;
|
if (!ep || !ep.models) return;
|
||||||
ep.models.forEach(m => {
|
sortModelIds(ep.models).forEach(m => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = m;
|
opt.value = m;
|
||||||
opt.textContent = m;
|
opt.textContent = m;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
|
|||||||
import searchModule from './search.js';
|
import searchModule from './search.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { clearDockSide } from './modalSnap.js';
|
import { clearDockSide } from './modalSnap.js';
|
||||||
|
import { sortModelIds } from './modelSort.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
@@ -31,6 +32,7 @@ function initTabs() {
|
|||||||
// they flip toggles instead of having to close + reopen the modal.
|
// they flip toggles instead of having to close + reopen the modal.
|
||||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||||
syncAppearanceOpacity(tab === 'appearance');
|
syncAppearanceOpacity(tab === 'appearance');
|
||||||
|
if (tab === 'ai') refreshAiModelEndpoints();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -160,6 +162,93 @@ function initOpacityToggle() {
|
|||||||
AI TAB
|
AI TAB
|
||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const _aiEndpointRefreshers = new Set();
|
||||||
|
let _aiEndpointRefreshInFlight = null;
|
||||||
|
|
||||||
|
async function _fetchModelEndpoints() {
|
||||||
|
const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||||
|
const endpoints = await epRes.json();
|
||||||
|
return Array.isArray(endpoints) ? endpoints : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _endpointLabel(ep) {
|
||||||
|
return ep.name + (ep.online ? '' : ' (offline)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
const previous = selected !== undefined ? selected : selectEl.value;
|
||||||
|
const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
|
||||||
|
? selectEl.options[0].textContent
|
||||||
|
: null;
|
||||||
|
while (selectEl.options.length) selectEl.remove(0);
|
||||||
|
if (blankText !== null) {
|
||||||
|
const blank = document.createElement('option');
|
||||||
|
blank.value = '';
|
||||||
|
blank.textContent = blankText;
|
||||||
|
selectEl.appendChild(blank);
|
||||||
|
}
|
||||||
|
(endpoints || []).forEach(function(ep) {
|
||||||
|
if (!ep.is_enabled) return;
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ep.id;
|
||||||
|
opt.textContent = _endpointLabel(ep);
|
||||||
|
selectEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
|
||||||
|
selectEl.value = previous;
|
||||||
|
} else if (blankText !== null) {
|
||||||
|
selectEl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fillModelSelect(selectEl, models, selected, keepBlank) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
const previous = selected !== undefined ? selected : selectEl.value;
|
||||||
|
const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
|
||||||
|
? selectEl.options[0].textContent
|
||||||
|
: null;
|
||||||
|
while (selectEl.options.length) selectEl.remove(0);
|
||||||
|
if (blankText !== null) {
|
||||||
|
const blank = document.createElement('option');
|
||||||
|
blank.value = '';
|
||||||
|
blank.textContent = blankText;
|
||||||
|
selectEl.appendChild(blank);
|
||||||
|
}
|
||||||
|
sortModelIds(models).forEach(function(m) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m;
|
||||||
|
opt.textContent = String(m).split('/').pop();
|
||||||
|
selectEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
|
||||||
|
selectEl.value = previous;
|
||||||
|
} else if (blankText !== null) {
|
||||||
|
selectEl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _registerAiEndpointRefresh(fn) {
|
||||||
|
_aiEndpointRefreshers.add(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAiModelEndpoints() {
|
||||||
|
if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight;
|
||||||
|
_aiEndpointRefreshInFlight = (async function() {
|
||||||
|
try {
|
||||||
|
const endpoints = await _fetchModelEndpoints();
|
||||||
|
_aiEndpointRefreshers.forEach(function(fn) {
|
||||||
|
try { fn(endpoints); } catch (e) { console.warn('[settings] endpoint refresh handler failed', e); }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[settings] failed to refresh model endpoints', e);
|
||||||
|
} finally {
|
||||||
|
_aiEndpointRefreshInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return _aiEndpointRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared fallback-chain widget — mirrors the Default Chat Model fallback UI
|
/* Shared fallback-chain widget — mirrors the Default Chat Model fallback UI
|
||||||
* for other model cards (Utility, Vision, …). Pass in the container/button
|
* for other model cards (Utility, Vision, …). Pass in the container/button
|
||||||
* IDs, the endpoints list, the settings key to persist under, and the
|
* IDs, the endpoints list, the settings key to persist under, and the
|
||||||
@@ -181,7 +270,7 @@ function _bindFallbackWidget(opts) {
|
|||||||
while (selectEl.options.length) selectEl.remove(0);
|
while (selectEl.options.length) selectEl.remove(0);
|
||||||
var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; });
|
var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; });
|
||||||
if (ep && ep.models) {
|
if (ep && ep.models) {
|
||||||
ep.models.forEach(function(m) {
|
sortModelIds(ep.models).forEach(function(m) {
|
||||||
if (!modelsFilter(m, ep)) return;
|
if (!modelsFilter(m, ep)) return;
|
||||||
var o = document.createElement('option');
|
var o = document.createElement('option');
|
||||||
o.value = m;
|
o.value = m;
|
||||||
@@ -270,6 +359,7 @@ function _bindFallbackWidget(opts) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setInitial: function(list) { current = (list || []).slice(); render(); },
|
setInitial: function(list) { current = (list || []).slice(); render(); },
|
||||||
|
refresh: render,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,31 +379,21 @@ async function initDefaultChat() {
|
|||||||
|
|
||||||
// Fill any <select> with the models for a given endpoint id.
|
// Fill any <select> with the models for a given endpoint id.
|
||||||
function fillModels(selectEl, epId, selected) {
|
function fillModels(selectEl, epId, selected) {
|
||||||
while (selectEl.options.length) selectEl.remove(0);
|
|
||||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||||
if (ep && ep.models) {
|
_fillModelSelect(selectEl, ep ? ep.models : [], selected, false);
|
||||||
ep.models.forEach(function(m) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = m;
|
|
||||||
opt.textContent = m.split('/').pop();
|
|
||||||
selectEl.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (selected) selectEl.value = selected;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
_endpoints = await _fetchModelEndpoints();
|
||||||
_endpoints = await epRes.json();
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, false);
|
||||||
enabledEndpoints().forEach(function(ep) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = ep.id;
|
|
||||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
|
||||||
epSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('Failed to load endpoints for default chat', e); }
|
} catch (e) { console.warn('Failed to load endpoints for default chat', e); }
|
||||||
|
|
||||||
function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); }
|
function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); }
|
||||||
|
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
||||||
|
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
||||||
|
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
||||||
|
renderFallbacks();
|
||||||
|
}
|
||||||
|
|
||||||
// Render the fallback chain. Each row is endpoint + model + remove.
|
// Render the fallback chain. Each row is endpoint + model + remove.
|
||||||
function renderFallbacks() {
|
function renderFallbacks() {
|
||||||
@@ -409,6 +489,11 @@ async function initDefaultChat() {
|
|||||||
renderFallbacks();
|
renderFallbacks();
|
||||||
saveDefault();
|
saveDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_registerAiEndpointRefresh(function(endpoints) {
|
||||||
|
_endpoints = endpoints;
|
||||||
|
refreshEndpointOptions(epSel.value, modelSel.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Utility Model ── */
|
/* ── Utility Model ── */
|
||||||
@@ -417,35 +502,19 @@ async function initUtilityModel() {
|
|||||||
var modelSel = el('set-utilityModelSelect');
|
var modelSel = el('set-utilityModelSelect');
|
||||||
var msg = el('set-utilityChatMsg');
|
var msg = el('set-utilityChatMsg');
|
||||||
var _endpoints = [];
|
var _endpoints = [];
|
||||||
|
var fallbackWidget = null;
|
||||||
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
|
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
|
||||||
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
|
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
_endpoints = await _fetchModelEndpoints();
|
||||||
_endpoints = await epRes.json();
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||||
_endpoints.forEach(function(ep) {
|
|
||||||
if (!ep.is_enabled) return;
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = ep.id;
|
|
||||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
|
||||||
epSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
|
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
|
||||||
|
|
||||||
function refreshModels(selectedModel) {
|
function refreshModels(selectedModel) {
|
||||||
while (modelSel.options.length > 1) modelSel.remove(1);
|
|
||||||
var epId = epSel.value;
|
var epId = epSel.value;
|
||||||
if (!epId) { modelSel.value = ''; return; }
|
|
||||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||||
if (ep && ep.models) {
|
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||||
ep.models.forEach(function(m) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = m;
|
|
||||||
opt.textContent = m.split('/').pop();
|
|
||||||
modelSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (selectedModel) modelSel.value = selectedModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -453,7 +522,7 @@ async function initUtilityModel() {
|
|||||||
var settings = await res.json();
|
var settings = await res.json();
|
||||||
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
|
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
|
||||||
refreshModels(settings.utility_model || '');
|
refreshModels(settings.utility_model || '');
|
||||||
_bindFallbackWidget({
|
fallbackWidget = _bindFallbackWidget({
|
||||||
containerId: 'set-utilityFallbacks',
|
containerId: 'set-utilityFallbacks',
|
||||||
addBtnId: 'set-utilityAddFallback',
|
addBtnId: 'set-utilityAddFallback',
|
||||||
endpoints: function() { return _endpoints; },
|
endpoints: function() { return _endpoints; },
|
||||||
@@ -483,6 +552,13 @@ async function initUtilityModel() {
|
|||||||
|
|
||||||
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
|
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
|
||||||
modelSel.addEventListener('change', saveUtility);
|
modelSel.addEventListener('change', saveUtility);
|
||||||
|
|
||||||
|
_registerAiEndpointRefresh(function(endpoints) {
|
||||||
|
_endpoints = endpoints;
|
||||||
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||||
|
refreshModels(modelSel.value);
|
||||||
|
if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Teacher Model ── */
|
/* ── Teacher Model ── */
|
||||||
@@ -501,31 +577,14 @@ async function initTeacherModel() {
|
|||||||
var _endpoints = [];
|
var _endpoints = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
_endpoints = await _fetchModelEndpoints();
|
||||||
_endpoints = await epRes.json();
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||||
_endpoints.forEach(function(ep) {
|
|
||||||
if (!ep.is_enabled) return;
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = ep.id;
|
|
||||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
|
||||||
epSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('Failed to load endpoints for teacher model', e); }
|
} catch (e) { console.warn('Failed to load endpoints for teacher model', e); }
|
||||||
|
|
||||||
function refreshModels(selectedModel) {
|
function refreshModels(selectedModel) {
|
||||||
while (modelSel.options.length > 1) modelSel.remove(1);
|
|
||||||
var epId = epSel.value;
|
var epId = epSel.value;
|
||||||
if (!epId) { modelSel.value = ''; return; }
|
|
||||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||||
if (ep && ep.models) {
|
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||||
ep.models.forEach(function(m) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = m;
|
|
||||||
opt.textContent = m.split('/').pop();
|
|
||||||
modelSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (selectedModel) modelSel.value = selectedModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable / enable the endpoint+model dropdowns based on the
|
// Disable / enable the endpoint+model dropdowns based on the
|
||||||
@@ -595,6 +654,12 @@ async function initTeacherModel() {
|
|||||||
}
|
}
|
||||||
epSel.addEventListener('change', function() { refreshModels(''); saveTeacher(); });
|
epSel.addEventListener('change', function() { refreshModels(''); saveTeacher(); });
|
||||||
modelSel.addEventListener('change', saveTeacher);
|
modelSel.addEventListener('change', saveTeacher);
|
||||||
|
|
||||||
|
_registerAiEndpointRefresh(function(endpoints) {
|
||||||
|
_endpoints = endpoints;
|
||||||
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||||
|
refreshModels(modelSel.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Image Generation ── */
|
/* ── Image Generation ── */
|
||||||
@@ -624,7 +689,7 @@ async function initImageSettings() {
|
|||||||
if (_isInpaintModel(mid)) imageModels.push(mid);
|
if (_isInpaintModel(mid)) imageModels.push(mid);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
imageModels.forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); });
|
sortModelIds(imageModels).forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); });
|
||||||
// Hardcoded fallbacks shown as "(not detected)" so users know what to
|
// Hardcoded fallbacks shown as "(not detected)" so users know what to
|
||||||
// download/serve to enable inpaint here.
|
// download/serve to enable inpaint here.
|
||||||
['stable-diffusion-3.5-medium', 'stable-diffusion-inpainting'].forEach(mid => {
|
['stable-diffusion-3.5-medium', 'stable-diffusion-inpainting'].forEach(mid => {
|
||||||
@@ -666,6 +731,7 @@ async function initVisionSettings() {
|
|||||||
const enabledToggle = el('set-visionEnabledToggle');
|
const enabledToggle = el('set-visionEnabledToggle');
|
||||||
const configWrap = vlSel ? vlSel.closest('div[style*="flex-direction"]') : null;
|
const configWrap = vlSel ? vlSel.closest('div[style*="flex-direction"]') : null;
|
||||||
var _visionEndpoints = [];
|
var _visionEndpoints = [];
|
||||||
|
var visionFallbackWidget = null;
|
||||||
var _vlExclude = ['audio', 'realtime', 'tts', 'dall-e', 'embedding', 'search', 'whisper'];
|
var _vlExclude = ['audio', 'realtime', 'tts', 'dall-e', 'embedding', 'search', 'whisper'];
|
||||||
function _isVisionModel(mid) {
|
function _isVisionModel(mid) {
|
||||||
var lower = String(mid || '').toLowerCase();
|
var lower = String(mid || '').toLowerCase();
|
||||||
@@ -674,27 +740,30 @@ async function initVisionSettings() {
|
|||||||
try {
|
try {
|
||||||
const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
|
const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
|
||||||
const modelsData = await modelsRes.json();
|
const modelsData = await modelsRes.json();
|
||||||
|
const visionModels = [];
|
||||||
(modelsData.items || []).forEach(item => {
|
(modelsData.items || []).forEach(item => {
|
||||||
if (item.offline) return;
|
if (item.offline) return;
|
||||||
(item.models || []).forEach(mid => {
|
(item.models || []).forEach(mid => {
|
||||||
if (_isVisionModel(mid)) {
|
if (_isVisionModel(mid)) {
|
||||||
var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt);
|
visionModels.push(mid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
sortModelIds(visionModels).forEach(mid => {
|
||||||
|
var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt);
|
||||||
|
});
|
||||||
} catch (e) { console.warn('Failed to load models for vision settings', e); }
|
} catch (e) { console.warn('Failed to load models for vision settings', e); }
|
||||||
// Also pull the raw endpoint list so the fallback widget can resolve
|
// Also pull the raw endpoint list so the fallback widget can resolve
|
||||||
// endpoint-id → models the same way the other cards do.
|
// endpoint-id → models the same way the other cards do.
|
||||||
try {
|
try {
|
||||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
_visionEndpoints = await _fetchModelEndpoints();
|
||||||
_visionEndpoints = await epRes.json();
|
|
||||||
} catch (e) { console.warn('Failed to load endpoints for vision fallback', e); }
|
} catch (e) { console.warn('Failed to load endpoints for vision fallback', e); }
|
||||||
try {
|
try {
|
||||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||||
const settings = await settingsRes.json();
|
const settings = await settingsRes.json();
|
||||||
if (settings.vision_model) vlSel.value = settings.vision_model;
|
if (settings.vision_model) vlSel.value = settings.vision_model;
|
||||||
if (enabledToggle) enabledToggle.checked = settings.vision_enabled !== false;
|
if (enabledToggle) enabledToggle.checked = settings.vision_enabled !== false;
|
||||||
_bindFallbackWidget({
|
visionFallbackWidget = _bindFallbackWidget({
|
||||||
containerId: 'set-visionFallbacks',
|
containerId: 'set-visionFallbacks',
|
||||||
addBtnId: 'set-visionAddFallback',
|
addBtnId: 'set-visionAddFallback',
|
||||||
endpoints: function() { return _visionEndpoints; },
|
endpoints: function() { return _visionEndpoints; },
|
||||||
@@ -725,6 +794,11 @@ async function initVisionSettings() {
|
|||||||
}
|
}
|
||||||
vlSel.addEventListener('change', saveSettings);
|
vlSel.addEventListener('change', saveSettings);
|
||||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); });
|
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); });
|
||||||
|
|
||||||
|
_registerAiEndpointRefresh(function(endpoints) {
|
||||||
|
_visionEndpoints = endpoints;
|
||||||
|
if (visionFallbackWidget && visionFallbackWidget.refresh) visionFallbackWidget.refresh();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Face Recognition ── */
|
/* ── Face Recognition ── */
|
||||||
@@ -1292,44 +1366,24 @@ async function initResearchSettings() {
|
|||||||
var modelSel = el('set-researchModel');
|
var modelSel = el('set-researchModel');
|
||||||
var tokensInput = el('set-researchMaxTokens');
|
var tokensInput = el('set-researchMaxTokens');
|
||||||
var msg = el('set-researchMsg');
|
var msg = el('set-researchMsg');
|
||||||
|
var endpoints = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
endpoints = await _fetchModelEndpoints();
|
||||||
var endpoints = await epRes.json();
|
_fillEndpointSelect(epSel, endpoints, epSel.value, true);
|
||||||
endpoints.forEach(function(ep) {
|
|
||||||
if (!ep.is_enabled) return;
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = ep.id;
|
|
||||||
opt.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
|
||||||
epSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('Failed to load endpoints for research', e); }
|
} catch (e) { console.warn('Failed to load endpoints for research', e); }
|
||||||
|
|
||||||
async function refreshModels(selectedModel) {
|
function refreshModels(selectedModel) {
|
||||||
var epId = epSel.value;
|
var epId = epSel.value;
|
||||||
while (modelSel.options.length > 1) modelSel.remove(1);
|
var ep = endpoints.find(function(e) { return e.id === epId; });
|
||||||
if (!epId) { modelSel.value = ''; return; }
|
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||||
try {
|
|
||||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
|
||||||
var endpoints = await epRes.json();
|
|
||||||
var ep = endpoints.find(function(e) { return e.id === epId; });
|
|
||||||
if (ep && ep.models) {
|
|
||||||
ep.models.forEach(function(m) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = m;
|
|
||||||
opt.textContent = m.split('/').pop();
|
|
||||||
modelSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (selectedModel) modelSel.value = selectedModel;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||||
var settings = await res.json();
|
var settings = await res.json();
|
||||||
if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id;
|
if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id;
|
||||||
await refreshModels(settings.research_model || '');
|
refreshModels(settings.research_model || '');
|
||||||
if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
|
if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
|
||||||
} catch (e) { console.warn('Failed to load research settings', e); }
|
} catch (e) { console.warn('Failed to load research settings', e); }
|
||||||
|
|
||||||
@@ -1371,11 +1425,17 @@ async function initResearchSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
epSel.addEventListener('change', async function() {
|
epSel.addEventListener('change', async function() {
|
||||||
await refreshModels('');
|
refreshModels('');
|
||||||
saveResearch();
|
saveResearch();
|
||||||
});
|
});
|
||||||
modelSel.addEventListener('change', saveResearch);
|
modelSel.addEventListener('change', saveResearch);
|
||||||
tokensInput.addEventListener('change', saveResearch);
|
tokensInput.addEventListener('change', saveResearch);
|
||||||
|
|
||||||
|
_registerAiEndpointRefresh(function(nextEndpoints) {
|
||||||
|
endpoints = nextEndpoints;
|
||||||
|
_fillEndpointSelect(epSel, endpoints, epSel.value, true);
|
||||||
|
refreshModels(modelSel.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Deep Research Search (Search tab) ── */
|
/* ── Deep Research Search (Search tab) ── */
|
||||||
@@ -4202,6 +4262,7 @@ export function open(tab) {
|
|||||||
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
|
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
|
||||||
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
||||||
syncAppearanceOpacity(activeTab === 'appearance');
|
syncAppearanceOpacity(activeTab === 'appearance');
|
||||||
|
if (activeTab === 'ai') refreshAiModelEndpoints();
|
||||||
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||||
window.adminModule._initData();
|
window.adminModule._initData();
|
||||||
}
|
}
|
||||||
@@ -4226,7 +4287,7 @@ export function close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility };
|
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||||
|
|
||||||
|
|
||||||
export default settingsModule;
|
export default settingsModule;
|
||||||
|
|||||||
@@ -47,13 +47,14 @@ const SETUP_PROVIDER_URLS = {
|
|||||||
deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
|
deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
|
||||||
openai: { name: 'OpenAI', url: 'https://api.openai.com/v1' },
|
openai: { name: 'OpenAI', url: 'https://api.openai.com/v1' },
|
||||||
openrouter: { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1' },
|
openrouter: { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1' },
|
||||||
|
ollama: { name: 'Ollama Cloud', url: 'https://ollama.com/api' },
|
||||||
xai: { name: 'xAI', url: 'https://api.x.ai/v1' },
|
xai: { name: 'xAI', url: 'https://api.x.ai/v1' },
|
||||||
anthropic: { name: 'Anthropic', url: 'https://api.anthropic.com/v1' },
|
anthropic: { name: 'Anthropic', url: 'https://api.anthropic.com/v1' },
|
||||||
groq: { name: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
groq: { name: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
||||||
gemini: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
gemini: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||||
google: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
google: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||||
};
|
};
|
||||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'xai', 'anthropic', 'groq', 'gemini'];
|
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini'];
|
||||||
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_NAMES[SETUP_PROVIDER_NAMES.length - 1];
|
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_NAMES[SETUP_PROVIDER_NAMES.length - 1];
|
||||||
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
||||||
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||||
@@ -67,6 +68,8 @@ function _setupProviderFromInput(input) {
|
|||||||
openai: 'openai',
|
openai: 'openai',
|
||||||
chatgpt: 'openai',
|
chatgpt: 'openai',
|
||||||
openrouter: 'openrouter',
|
openrouter: 'openrouter',
|
||||||
|
ollama: 'ollama',
|
||||||
|
ollamacloud: 'ollama',
|
||||||
anthropic: 'anthropic',
|
anthropic: 'anthropic',
|
||||||
claude: 'anthropic',
|
claude: 'anthropic',
|
||||||
groq: 'groq',
|
groq: 'groq',
|
||||||
@@ -84,6 +87,7 @@ function _extractSetupProviderCredential(input) {
|
|||||||
const providerAliases = [
|
const providerAliases = [
|
||||||
['deepseek ai', 'deepseek'], ['deepseek', 'deepseek'],
|
['deepseek ai', 'deepseek'], ['deepseek', 'deepseek'],
|
||||||
['open router', 'openrouter'], ['openrouter', 'openrouter'],
|
['open router', 'openrouter'], ['openrouter', 'openrouter'],
|
||||||
|
['ollama cloud', 'ollama'], ['ollama', 'ollama'],
|
||||||
['open ai', 'openai'], ['openai', 'openai'], ['chatgpt', 'openai'],
|
['open ai', 'openai'], ['openai', 'openai'], ['chatgpt', 'openai'],
|
||||||
['anthropic', 'anthropic'], ['claude', 'anthropic'],
|
['anthropic', 'anthropic'], ['claude', 'anthropic'],
|
||||||
['groq', 'groq'],
|
['groq', 'groq'],
|
||||||
@@ -488,8 +492,13 @@ function detectProvider(input) {
|
|||||||
for (const suffix of ['/models', '/chat/completions', '/completions', '/v1/messages']) {
|
for (const suffix of ['/models', '/chat/completions', '/completions', '/v1/messages']) {
|
||||||
if (url.endsWith(suffix)) url = url.slice(0, -suffix.length).replace(/\/+$/, '');
|
if (url.endsWith(suffix)) url = url.slice(0, -suffix.length).replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
url = url.replace(/\/api\/(chat|tags|generate)\/?$/i, '/api');
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.hostname.endsWith('ollama.com')) url = 'https://ollama.com/api';
|
||||||
|
} catch(e) {}
|
||||||
// Add /v1 if bare host:port
|
// Add /v1 if bare host:port
|
||||||
if (/^https?:\/\/[^/]+$/.test(url) && !url.includes('api.')) url += '/v1';
|
if (/^https?:\/\/[^/]+$/.test(url) && !url.includes('api.') && !url.includes('ollama.com')) url += '/v1';
|
||||||
return { base_url: url, api_key: '', name: '' };
|
return { base_url: url, api_key: '', name: '' };
|
||||||
}
|
}
|
||||||
// Known key patterns
|
// Known key patterns
|
||||||
@@ -507,6 +516,13 @@ function detectProvider(input) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupChatUrlForEndpoint(detected) {
|
||||||
|
const base = (detected.base_url || '').replace(/\/+$/, '');
|
||||||
|
if (detected.name === 'Anthropic') return base.replace(/\/v1$/, '') + '/v1/messages';
|
||||||
|
if (base.includes('ollama.com')) return 'https://ollama.com/api/chat';
|
||||||
|
return base + '/chat/completions';
|
||||||
|
}
|
||||||
|
|
||||||
async function connectDetectedSetupEndpoint(detected) {
|
async function connectDetectedSetupEndpoint(detected) {
|
||||||
const providerLabel = detected.name || 'custom endpoint';
|
const providerLabel = detected.name || 'custom endpoint';
|
||||||
const chatBox = document.getElementById('chat-history');
|
const chatBox = document.getElementById('chat-history');
|
||||||
@@ -555,7 +571,7 @@ async function connectDetectedSetupEndpoint(detected) {
|
|||||||
await typewriterReply(`Found ${count} model${count > 1 ? 's' : ''} on ${providerLabel}. Starting a chat...`);
|
await typewriterReply(`Found ${count} model${count > 1 ? 's' : ''} on ${providerLabel}. Starting a chat...`);
|
||||||
if (modelsModule) await modelsModule.refreshModels(true);
|
if (modelsModule) await modelsModule.refreshModels(true);
|
||||||
const firstModel = data.models[0];
|
const firstModel = data.models[0];
|
||||||
const chatUrl = detected.base_url + (detected.name === 'Anthropic' ? '/v1/messages' : '/chat/completions');
|
const chatUrl = setupChatUrlForEndpoint(detected);
|
||||||
if (sessionModule) {
|
if (sessionModule) {
|
||||||
await sessionModule.createDirectChat(chatUrl, firstModel, data.id);
|
await sessionModule.createDirectChat(chatUrl, firstModel, data.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
|
|||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import * as spinnerModule from './spinner.js';
|
import * as spinnerModule from './spinner.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
|
import { sortModelIds } from './modelSort.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -1259,7 +1260,7 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
|||||||
if (it.offline || !it.models || it.models.length === 0) continue;
|
if (it.offline || !it.models || it.models.length === 0) continue;
|
||||||
const group = document.createElement('optgroup');
|
const group = document.createElement('optgroup');
|
||||||
group.label = it.endpoint_name || it.host || 'endpoint';
|
group.label = it.endpoint_name || it.host || 'endpoint';
|
||||||
const all = [...(it.models || []), ...(it.models_extra || [])];
|
const all = sortModelIds([...(it.models || []), ...(it.models_extra || [])]);
|
||||||
for (const m of all) {
|
for (const m of all) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = `${it.url}::${m}`;
|
opt.value = `${it.url}::${m}`;
|
||||||
|
|||||||
@@ -11,15 +11,35 @@ def normalize_base(url: str) -> str:
|
|||||||
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
||||||
if url.endswith(suffix):
|
if url.endswith(suffix):
|
||||||
url = url[: -len(suffix)].rstrip("/")
|
url = url[: -len(suffix)].rstrip("/")
|
||||||
|
for suffix in ["/chat", "/tags", "/generate"]:
|
||||||
|
if url.endswith("/api" + suffix):
|
||||||
|
url = url[: -len(suffix)].rstrip("/")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _detect_provider(url: str) -> str:
|
def _detect_provider(url: str) -> str:
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if host.endswith("ollama.com") or (parsed.port == 11434 and (path == "/api" or path.startswith("/api/"))):
|
||||||
|
return "ollama"
|
||||||
if "anthropic.com" in (url or ""):
|
if "anthropic.com" in (url or ""):
|
||||||
return "anthropic"
|
return "anthropic"
|
||||||
return "openai"
|
return "openai"
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_api_root(base: str) -> str:
|
||||||
|
base = (base or "").strip().rstrip("/")
|
||||||
|
parsed = urlparse(base)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if path.endswith("/api"):
|
||||||
|
return base
|
||||||
|
if host.endswith("ollama.com"):
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}/api"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
def build_chat_url(base: str) -> str:
|
def build_chat_url(base: str) -> str:
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
@@ -27,9 +47,18 @@ def build_chat_url(base: str) -> str:
|
|||||||
if host.endswith("anthropic.com") and base.rstrip("/").endswith("/v1"):
|
if host.endswith("anthropic.com") and base.rstrip("/").endswith("/v1"):
|
||||||
base = base.rstrip("/")[:-3].rstrip("/")
|
base = base.rstrip("/")[:-3].rstrip("/")
|
||||||
return base + "/v1/messages"
|
return base + "/v1/messages"
|
||||||
|
if provider == "ollama":
|
||||||
|
return _ollama_api_root(base) + "/chat"
|
||||||
return base + "/chat/completions"
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def build_models_url(base: str) -> str:
|
||||||
|
provider = _detect_provider(base)
|
||||||
|
if provider == "ollama":
|
||||||
|
return _ollama_api_root(base) + "/tags"
|
||||||
|
return base + "/models"
|
||||||
|
|
||||||
|
|
||||||
def build_headers(api_key, base: str) -> dict:
|
def build_headers(api_key, base: str) -> dict:
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {}
|
return {}
|
||||||
@@ -52,6 +81,9 @@ class TestNormalizeBase:
|
|||||||
def test_strips_v1_messages(self):
|
def test_strips_v1_messages(self):
|
||||||
assert normalize_base("https://api.anthropic.com/v1/messages") == "https://api.anthropic.com"
|
assert normalize_base("https://api.anthropic.com/v1/messages") == "https://api.anthropic.com"
|
||||||
|
|
||||||
|
def test_strips_ollama_native_chat(self):
|
||||||
|
assert normalize_base("https://ollama.com/api/chat") == "https://ollama.com/api"
|
||||||
|
|
||||||
def test_trailing_slash(self):
|
def test_trailing_slash(self):
|
||||||
assert normalize_base("https://api.openai.com/v1/") == "https://api.openai.com/v1"
|
assert normalize_base("https://api.openai.com/v1/") == "https://api.openai.com/v1"
|
||||||
|
|
||||||
@@ -78,6 +110,20 @@ class TestBuildChatUrl:
|
|||||||
def test_local_endpoint(self):
|
def test_local_endpoint(self):
|
||||||
assert build_chat_url("http://localhost:8000/v1") == "http://localhost:8000/v1/chat/completions"
|
assert build_chat_url("http://localhost:8000/v1") == "http://localhost:8000/v1/chat/completions"
|
||||||
|
|
||||||
|
def test_ollama_cloud_native_api(self):
|
||||||
|
assert build_chat_url("https://ollama.com/api") == "https://ollama.com/api/chat"
|
||||||
|
|
||||||
|
def test_ollama_cloud_root_adds_api(self):
|
||||||
|
assert build_chat_url("https://ollama.com") == "https://ollama.com/api/chat"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildModelsUrl:
|
||||||
|
def test_openai_models(self):
|
||||||
|
assert build_models_url("https://api.openai.com/v1") == "https://api.openai.com/v1/models"
|
||||||
|
|
||||||
|
def test_ollama_tags(self):
|
||||||
|
assert build_models_url("https://ollama.com/api") == "https://ollama.com/api/tags"
|
||||||
|
|
||||||
|
|
||||||
class TestBuildHeaders:
|
class TestBuildHeaders:
|
||||||
def test_no_key(self):
|
def test_no_key(self):
|
||||||
|
|||||||
43
tests/test_llm_core_ollama.py
Normal file
43
tests/test_llm_core_ollama.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Regression tests for native Ollama Cloud provider handling."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src import llm_core
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_ollama_cloud_native_provider():
|
||||||
|
assert llm_core._detect_provider("https://ollama.com/api") == "ollama"
|
||||||
|
assert llm_core._detect_provider("https://ollama.com/api/chat") == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_call_posts_native_ollama_payload(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, json=None, timeout=None):
|
||||||
|
seen["url"] = url
|
||||||
|
seen["headers"] = headers
|
||||||
|
seen["json"] = json
|
||||||
|
seen["timeout"] = timeout
|
||||||
|
request = httpx.Request("POST", url)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
request=request,
|
||||||
|
json={"message": {"content": "OK"}, "done": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core.httpx, "post", fake_post)
|
||||||
|
|
||||||
|
result = llm_core.llm_call(
|
||||||
|
"https://ollama.com/api",
|
||||||
|
"gpt-oss:120b-test",
|
||||||
|
[{"role": "user", "content": "Say OK"}],
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=7,
|
||||||
|
headers={"Authorization": "Bearer ollama-key"},
|
||||||
|
timeout=11,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert seen["url"] == "https://ollama.com/api/chat"
|
||||||
|
assert seen["headers"]["Authorization"] == "Bearer ollama-key"
|
||||||
|
assert seen["json"]["stream"] is False
|
||||||
|
assert seen["json"]["options"] == {"temperature": 0.2, "num_predict": 7}
|
||||||
@@ -65,6 +65,9 @@ class TestMatchProviderCurated:
|
|||||||
def test_xai_url(self):
|
def test_xai_url(self):
|
||||||
assert _match_provider_curated("https://api.x.ai/v1", "openai") == "xai"
|
assert _match_provider_curated("https://api.x.ai/v1", "openai") == "xai"
|
||||||
|
|
||||||
|
def test_ollama_url(self):
|
||||||
|
assert _match_provider_curated("https://ollama.com/api", "openai") == "ollama"
|
||||||
|
|
||||||
def test_no_url_match_returns_provider(self):
|
def test_no_url_match_returns_provider(self):
|
||||||
assert _match_provider_curated("https://localhost:1234", "openai") == "openai"
|
assert _match_provider_curated("https://localhost:1234", "openai") == "openai"
|
||||||
|
|
||||||
@@ -263,6 +266,26 @@ class TestSetupProbeSafety:
|
|||||||
assert _probe_endpoint("https://api.anthropic.com/v1", "good-key") == ["claude-sonnet-4-5"]
|
assert _probe_endpoint("https://api.anthropic.com/v1", "good-key") == ["claude-sonnet-4-5"]
|
||||||
assert seen == ["https://api.anthropic.com/v1/models"]
|
assert seen == ["https://api.anthropic.com/v1/models"]
|
||||||
|
|
||||||
|
def test_ollama_cloud_probe_uses_native_tags_endpoint(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||||
|
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_get(url, headers=None, timeout=None):
|
||||||
|
seen.append((url, headers))
|
||||||
|
request = httpx.Request("GET", url)
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
request=request,
|
||||||
|
json={"models": [{"name": "gpt-oss:120b"}, {"model": "qwen3:235b"}]},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||||
|
|
||||||
|
assert _probe_endpoint("https://ollama.com/api", "ollama-key") == ["gpt-oss:120b", "qwen3:235b"]
|
||||||
|
assert seen == [("https://ollama.com/api/tags", {"Authorization": "Bearer ollama-key"})]
|
||||||
|
|
||||||
def test_unkeyed_anthropic_probe_can_use_curated_fallback(self, monkeypatch):
|
def test_unkeyed_anthropic_probe_can_use_curated_fallback(self, monkeypatch):
|
||||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||||
|
|||||||
Reference in New Issue
Block a user