mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 14:12:22 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ccf250a4 | ||
|
|
72c0bde8a9 | ||
|
|
2e16394b41 | ||
|
|
060dbf0681 | ||
|
|
d9ad418195 | ||
|
|
08994a0a96 | ||
|
|
e9136f801a | ||
|
|
e90dbc1012 | ||
|
|
d47715036a | ||
|
|
87407b3a09 |
+30
@@ -1,3 +1,14 @@
|
|||||||
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
|
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
||||||
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
|
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||||
|
FROM python:3.14-slim AS realesrgan-wheels
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
|
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||||
|
|
||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
@@ -18,8 +29,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
tmux \
|
tmux \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
gosu \
|
gosu \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
libxcb1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
|
||||||
|
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
|
||||||
|
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
|
||||||
|
# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
|
||||||
|
# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
|
||||||
|
# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
|
||||||
|
|
||||||
# Docker CLI (client only — daemon stays on the host via the
|
# Docker CLI (client only — daemon stays on the host via the
|
||||||
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
||||||
# dockerd but not the client binary on slim, so grab the static client
|
# dockerd but not the client binary on slim, so grab the static client
|
||||||
@@ -46,6 +67,15 @@ COPY requirements.txt requirements-optional.txt ./
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt \
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
||||||
|
|
||||||
|
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
|
||||||
|
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
|
||||||
|
# pulled only when realesrgan is actually installed). With these dists already
|
||||||
|
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
|
||||||
|
# wheels instead of rebuilding the sdists that fail on Python 3.14.
|
||||||
|
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
|
||||||
|
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
||||||
|
&& rm -rf /tmp/odysseus-wheels
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
||||||
|
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
||||||
|
# automatically. But the VS Code debugger (and other non-uvicorn entrypoints)
|
||||||
|
# use the default SelectorEventLoop, which raises NotImplementedError on any
|
||||||
|
# subprocess call. Force ProactorEventLoop here so the right loop is always
|
||||||
|
# used, regardless of how the process is launched.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
|
||||||
|
|
||||||
def register_static_mime_types() -> None:
|
def register_static_mime_types() -> None:
|
||||||
@@ -796,10 +806,12 @@ async def serve_index(request: Request):
|
|||||||
static_path = abs_join(BASE_DIR, "static/index.html")
|
static_path = abs_join(BASE_DIR, "static/index.html")
|
||||||
if os.path.exists(static_path):
|
if os.path.exists(static_path):
|
||||||
return serve_html_with_nonce(request, static_path)
|
return serve_html_with_nonce(request, static_path)
|
||||||
root_path = abs_join(BASE_DIR, "index.html")
|
# No static bundle — fall back to a root-level index.html if one is shipped.
|
||||||
if os.path.exists(root_path):
|
# If neither exists, serve_html_with_nonce logs it and returns a generic 500:
|
||||||
return serve_html_with_nonce(request, root_path)
|
# a missing index.html is a broken deployment (server fault), not a client
|
||||||
raise HTTPException(404, "index.html not found")
|
# "not found". This keeps the app-shell route consistent with the other
|
||||||
|
# bundled-template routes instead of mislabelling the fault as a 404.
|
||||||
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "index.html"))
|
||||||
|
|
||||||
@app.get("/notes")
|
@app.get("/notes")
|
||||||
async def serve_notes(request: Request):
|
async def serve_notes(request: Request):
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build patched wheels for Real-ESRGAN's unmaintained dependencies.
|
||||||
|
#
|
||||||
|
# basicsr / gfpgan / facexlib (xinntao, last released 2022) read their version
|
||||||
|
# in setup.py with:
|
||||||
|
#
|
||||||
|
# exec(compile(f.read(), version_file, 'exec'))
|
||||||
|
# return locals()['__version__']
|
||||||
|
#
|
||||||
|
# Python 3.13+ implements PEP 667: locals() inside a function returns an
|
||||||
|
# independent snapshot that exec() can no longer mutate, so the read raises
|
||||||
|
# `KeyError: '__version__'` and the sdist build fails. That is why the Cookbook
|
||||||
|
# "install realesrgan" button dies on the python:3.14 image. The packages have
|
||||||
|
# no fixed release, so we patch get_version() to exec into an explicit namespace
|
||||||
|
# dict (works on every Python) and build wheels from the patched source.
|
||||||
|
#
|
||||||
|
# Usage: build-realesrgan-wheels.sh [OUTPUT_DIR] (default: /wheels)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="${1:-/wheels}"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
|
||||||
|
# Pinned to the versions Real-ESRGAN 0.3.0 resolves to.
|
||||||
|
SPECS="basicsr==1.4.2 gfpgan==1.3.8 facexlib==0.3.0"
|
||||||
|
|
||||||
|
for spec in $SPECS; do
|
||||||
|
name="${spec%%==*}"
|
||||||
|
ver="${spec##*==}"
|
||||||
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
|
# sdist URL from the PyPI JSON API instead.
|
||||||
|
url="$(python - "$name" "$ver" <<'PY'
|
||||||
|
import json, sys, urllib.request
|
||||||
|
name, ver = sys.argv[1], sys.argv[2]
|
||||||
|
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||||
|
for f in data["urls"]:
|
||||||
|
if f["packagetype"] == "sdist":
|
||||||
|
print(f["url"]); break
|
||||||
|
else:
|
||||||
|
sys.exit(f"no sdist found for {name}=={ver}")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo ">> fetching ${name} ${ver}: ${url}"
|
||||||
|
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||||
|
tar xzf "${name}.tar.gz"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ">> patching get_version()"
|
||||||
|
python - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
old_exec = "exec(compile(f.read(), version_file, 'exec'))"
|
||||||
|
new_exec = "_ver_ns = {}\n exec(compile(f.read(), version_file, 'exec'), _ver_ns)"
|
||||||
|
old_ret = "return locals()['__version__']"
|
||||||
|
new_ret = "return _ver_ns['__version__']"
|
||||||
|
patched = 0
|
||||||
|
for setup in pathlib.Path(".").glob("*/setup.py"):
|
||||||
|
s = setup.read_text()
|
||||||
|
if old_exec in s and old_ret in s:
|
||||||
|
setup.write_text(s.replace(old_exec, new_exec).replace(old_ret, new_ret))
|
||||||
|
print(" patched", setup)
|
||||||
|
patched += 1
|
||||||
|
assert patched == 3, f"expected to patch 3 setup.py files, patched {patched}"
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo ">> building wheels into ${OUT}"
|
||||||
|
pip wheel --no-deps -w "$OUT" ./basicsr-* ./gfpgan-* ./facexlib-*
|
||||||
|
ls -l "$OUT"
|
||||||
+17
-14
@@ -694,20 +694,23 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
|
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
||||||
# Record we processed this email so we don't re-LLM next run
|
else:
|
||||||
try:
|
# Record we processed this email so we don't re-LLM next run.
|
||||||
_cc = _sql3.connect(SCHEDULED_DB)
|
# Only mark as processed on success ? transient LLM failures
|
||||||
_cc.execute(
|
# are retried on the next poll run (matches summary/reply pattern).
|
||||||
"INSERT OR REPLACE INTO email_calendar_extractions "
|
try:
|
||||||
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
|
_cc = _sql3.connect(SCHEDULED_DB)
|
||||||
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
_cc.execute(
|
||||||
_cal_run_count, datetime.utcnow().isoformat())
|
"INSERT OR REPLACE INTO email_calendar_extractions "
|
||||||
)
|
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
_cc.commit()
|
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||||
_cc.close()
|
_cal_run_count, datetime.utcnow().isoformat())
|
||||||
_cal_existing.add(message_id)
|
)
|
||||||
except Exception as ce:
|
_cc.commit()
|
||||||
logger.debug(f"Could not cache calendar extraction: {ce}")
|
_cc.close()
|
||||||
|
_cal_existing.add(message_id)
|
||||||
|
except Exception as ce:
|
||||||
|
logger.debug(f"Could not cache calendar extraction: {ce}")
|
||||||
|
|
||||||
if need_urgent:
|
if need_urgent:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2108,6 +2108,16 @@ def setup_model_routes(model_discovery):
|
|||||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||||
model = (_user_prefs.get("default_model") or "").strip()
|
model = (_user_prefs.get("default_model") or "").strip()
|
||||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||||
|
# If user has no personal default, fall back to global default
|
||||||
|
# But only based on the "share_defaults_with_users" flag
|
||||||
|
# (only if share_defaults_with_users is enabled)
|
||||||
|
if settings.get("share_defaults_with_users", False):
|
||||||
|
if not ep_id:
|
||||||
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
|
if not model:
|
||||||
|
model = settings.get("default_model", "")
|
||||||
|
if not _fallbacks:
|
||||||
|
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||||
else:
|
else:
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
|
|||||||
@@ -1377,11 +1377,16 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
except importlib_metadata.PackageNotFoundError:
|
except importlib_metadata.PackageNotFoundError:
|
||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
except Exception:
|
except (Exception, SystemExit):
|
||||||
# Installed but crashes on import — e.g. a CUDA build of
|
# Installed but crashes on import — e.g. a CUDA build of
|
||||||
# llama-cpp-python raising FileNotFoundError when the CUDA
|
# llama-cpp-python raising FileNotFoundError when the CUDA
|
||||||
# toolkit dir is absent. One broken optional package must not
|
# toolkit dir is absent, or rembg calling sys.exit(1) when no
|
||||||
# 500 the entire packages panel; report it as not usable.
|
# onnxruntime backend can be loaded. SystemExit is a
|
||||||
|
# BaseException, not Exception, so without catching it here a
|
||||||
|
# single sys.exit-on-import package escapes and takes down the
|
||||||
|
# whole packages panel / worker (the panel hangs forever). One
|
||||||
|
# broken optional package must not 500 — or hang — the entire
|
||||||
|
# panel; report it as not usable.
|
||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
|
|
||||||
# llama_cpp partial-state probe: when the package is installed
|
# llama_cpp partial-state probe: when the package is installed
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ def check_arch():
|
|||||||
def main():
|
def main():
|
||||||
print("\n=== Odysseus Setup ===\n")
|
print("\n=== Odysseus Setup ===\n")
|
||||||
|
|
||||||
|
# Load .env so pre-seeded ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD (and
|
||||||
|
# other deployment vars) are honored on native installs, not just when they
|
||||||
|
# are exported in the shell. Mirrors app.py: encoding="utf-8-sig" tolerates a
|
||||||
|
# UTF-8 BOM in a Notepad-saved .env. load_dotenv does not override already
|
||||||
|
# exported OS env vars, so the existing precedence is preserved. python-dotenv
|
||||||
|
# is a hard dependency (requirements.txt) and is verified by check_deps below.
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(BASE_DIR, ".env"), encoding="utf-8-sig")
|
||||||
|
|
||||||
# Fail fast with a clear message if the CPU architecture is wrong (Apple
|
# Fail fast with a clear message if the CPU architecture is wrong (Apple
|
||||||
# Silicon under an x86/Rosetta Python) before importing anything native.
|
# Silicon under an x86/Rosetta Python) before importing anything native.
|
||||||
check_arch()
|
check_arch()
|
||||||
|
|||||||
+17
-2
@@ -81,11 +81,26 @@ class APIKeyManager:
|
|||||||
keys stay encrypted. Loading via load() first would decrypt them and
|
keys stay encrypted. Loading via load() first would decrypt them and
|
||||||
write them back as plaintext, which then fails to decrypt on the next
|
write them back as plaintext, which then fails to decrypt on the next
|
||||||
load() and silently drops those providers.
|
load() and silently drops those providers.
|
||||||
|
|
||||||
|
Uses atomic write (temp file + os.replace) so a crash, disk-full, or
|
||||||
|
mid-write error never truncates the existing keys file.
|
||||||
"""
|
"""
|
||||||
keys = self._load_raw()
|
keys = self._load_raw()
|
||||||
keys[provider] = self.encrypt_api_key(api_key)
|
keys[provider] = self.encrypt_api_key(api_key)
|
||||||
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
|
tmp_file = self.api_keys_file + ".tmp"
|
||||||
json.dump(keys, f)
|
try:
|
||||||
|
with open(tmp_file, 'w', encoding="utf-8") as f:
|
||||||
|
json.dump(keys, f)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp_file, self.api_keys_file)
|
||||||
|
except OSError:
|
||||||
|
# Clean up temp file on failure; re-raise so callers see the error
|
||||||
|
try:
|
||||||
|
os.remove(tmp_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
def load(self) -> Dict[str, str]:
|
def load(self) -> Dict[str, str]:
|
||||||
"""Load and decrypt API keys"""
|
"""Load and decrypt API keys"""
|
||||||
|
|||||||
+8
-1
@@ -777,10 +777,17 @@ def _provider_label(url: str) -> str:
|
|||||||
pass
|
pass
|
||||||
if _is_ollama_native_url(url): return "Ollama"
|
if _is_ollama_native_url(url): return "Ollama"
|
||||||
try:
|
try:
|
||||||
host = (urlparse(url).hostname or "").lower()
|
_parsed_local = urlparse(url)
|
||||||
|
host = (_parsed_local.hostname or "").lower()
|
||||||
|
port = _parsed_local.port
|
||||||
except Exception:
|
except Exception:
|
||||||
return "provider"
|
return "provider"
|
||||||
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}:
|
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}:
|
||||||
|
# A port alone is not authoritative: vLLM, SGLang, llama.cpp and plain
|
||||||
|
# OpenAI-compatible servers all routinely share 8000/8080, so naming the
|
||||||
|
# serving tool from the port here would mislabel real setups. The tool is
|
||||||
|
# identified by probing llama-server's native /props endpoint during
|
||||||
|
# discovery (see ModelDiscovery._fingerprint_provider); this stays neutral.
|
||||||
return "local endpoint"
|
return "local endpoint"
|
||||||
return host or "provider"
|
return host or "provider"
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -163,6 +163,21 @@ class ModelDiscovery:
|
|||||||
return "lmstudio"
|
return "lmstudio"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# llama.cpp's llama-server exposes a native /props endpoint (no /v1 prefix)
|
||||||
|
# describing the loaded model, slots, and chat template — distinct from
|
||||||
|
# LM Studio (/api/v1/models) and vLLM (/version, /metrics).
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"http://{host}:{port}/props", timeout=1.5)
|
||||||
|
if r.is_success:
|
||||||
|
props = r.json() or {}
|
||||||
|
if isinstance(props, dict) and (
|
||||||
|
"default_generation_settings" in props
|
||||||
|
or "total_slots" in props
|
||||||
|
or "chat_template" in props
|
||||||
|
):
|
||||||
|
return "llamacpp"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
|
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
|
||||||
@@ -194,10 +209,11 @@ class ModelDiscovery:
|
|||||||
|
|
||||||
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
|
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
|
||||||
|
|
||||||
# Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook),
|
# Well-known ports: 8000-8020 (vLLM, SGLang, Cookbook), 8080 (llama.cpp /
|
||||||
# 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is
|
# llama-server default), 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL
|
||||||
# occupied by Ollama. The env vars can add more ports which will be merged in.
|
# as its default port is occupied by Ollama. The env vars can add more
|
||||||
ports = list(range(8000, 8021)) + [1234, 11434, 11435]
|
# ports which will be merged in.
|
||||||
|
ports = list(range(8000, 8021)) + [8080, 1234, 11434, 11435]
|
||||||
ports += [p for p in sorted(self._extra_ports) if p not in ports]
|
ports += [p for p in sorted(self._extra_ports) if p not in ports]
|
||||||
targets = [(h, p) for h in hosts for p in ports]
|
targets = [(h, p) for h in hosts for p in ports]
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ DEFAULT_SETTINGS = {
|
|||||||
# before producing output (endpoint offline / errors), the chat
|
# before producing output (endpoint offline / errors), the chat
|
||||||
# dispatch retries the next entry in order.
|
# dispatch retries the next entry in order.
|
||||||
"default_model_fallbacks": [],
|
"default_model_fallbacks": [],
|
||||||
|
# When True, non-admin users inherit global default model/endpoint/fallbacks
|
||||||
|
# when they have no personal defaults. When False, users only use their
|
||||||
|
# personal defaults (no global fallback). Default is False.
|
||||||
|
"share_defaults_with_users": False,
|
||||||
"utility_endpoint_id": "",
|
"utility_endpoint_id": "",
|
||||||
"utility_model": "",
|
"utility_model": "",
|
||||||
# Ordered fallback chain for the Utility model (summarization, naming,
|
# Ordered fallback chain for the Utility model (summarization, naming,
|
||||||
|
|||||||
+84
-1
@@ -308,6 +308,88 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
|
|||||||
return ToolBlock("web_fetch", url)
|
return ToolBlock("web_fetch", url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_misfenced_read_file_lookup(content: str, *, allow_shell_style: bool = False) -> Optional[ToolBlock]:
|
||||||
|
"""Recover simple read_file calls wrapped in python/bash fences."""
|
||||||
|
stripped = content.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = ast.parse(stripped, mode="exec")
|
||||||
|
except SyntaxError:
|
||||||
|
module = None
|
||||||
|
if module and len(module.body) == 1 and isinstance(module.body[0], ast.Expr):
|
||||||
|
call = module.body[0].value
|
||||||
|
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name):
|
||||||
|
if call.func.id.lower() != "read_file" or len(call.args) > 1:
|
||||||
|
return None
|
||||||
|
args = {}
|
||||||
|
if call.args:
|
||||||
|
path = _literal_string(call.args[0])
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
args["path"] = path
|
||||||
|
allowed = {"path", "file", "file_path", "offset", "limit"}
|
||||||
|
for keyword in call.keywords:
|
||||||
|
if keyword.arg not in allowed:
|
||||||
|
return None
|
||||||
|
key = "path" if keyword.arg in ("file", "file_path") else keyword.arg
|
||||||
|
if key == "path":
|
||||||
|
path = _literal_string(keyword.value)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
args["path"] = path
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = ast.literal_eval(keyword.value)
|
||||||
|
except (ValueError, SyntaxError, TypeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(value, int) or value < 0:
|
||||||
|
return None
|
||||||
|
args[key] = value
|
||||||
|
if not args.get("path"):
|
||||||
|
return None
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
return function_call_to_tool_block("read_file", json.dumps(args))
|
||||||
|
|
||||||
|
if not allow_shell_style:
|
||||||
|
return None
|
||||||
|
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
||||||
|
if len(lines) != 1:
|
||||||
|
return None
|
||||||
|
match = re.fullmatch(r"read_file\s+(.+)", lines[0], re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
path = match.group(1).strip()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
if path.startswith("{"):
|
||||||
|
try:
|
||||||
|
args = json.loads(path)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return None
|
||||||
|
normalized = {}
|
||||||
|
raw_path = args.get("path") or args.get("file") or args.get("file_path")
|
||||||
|
if isinstance(raw_path, str) and raw_path.strip():
|
||||||
|
normalized["path"] = raw_path.strip()
|
||||||
|
for key in ("offset", "limit"):
|
||||||
|
value = args.get(key)
|
||||||
|
if isinstance(value, int) and value >= 0:
|
||||||
|
normalized[key] = value
|
||||||
|
if not normalized.get("path"):
|
||||||
|
return None
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
return function_call_to_tool_block("read_file", json.dumps(normalized))
|
||||||
|
if len(path) >= 2 and path[0] == path[-1] and path[0] in "'\"":
|
||||||
|
path = path[1:-1].strip()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
return ToolBlock("read_file", path)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_raw_web_query(value) -> Optional[str]:
|
def _coerce_raw_web_query(value) -> Optional[str]:
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(value, str) and value.strip():
|
||||||
return value.strip()
|
return value.strip()
|
||||||
@@ -704,7 +786,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
# _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code.
|
# _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code.
|
||||||
continue
|
continue
|
||||||
if tag in ("python", "bash"):
|
if tag in ("python", "bash"):
|
||||||
block = _parse_misfenced_web_lookup(content)
|
block = (_parse_misfenced_web_lookup(content)
|
||||||
|
or _parse_misfenced_read_file_lookup(content, allow_shell_style=(tag == "bash")))
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
continue
|
continue
|
||||||
|
|||||||
+5
-3
@@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
if (!sessionModule) return false;
|
if (!sessionModule) return false;
|
||||||
|
|
||||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||||
if (pending && pending.url && pending.modelId) {
|
if (pending && pending.url && pending.modelId && pending.endpointId) {
|
||||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
const sessions = sessionModule.getSessions();
|
const sessions = sessionModule.getSessions();
|
||||||
const currentId = sessionModule.getCurrentSessionId();
|
const currentId = sessionModule.getCurrentSessionId();
|
||||||
const current = sessions.find(s => s.id === currentId);
|
const current = sessions.find(s => s.id === currentId);
|
||||||
if (current && current.endpoint_url && current.model) {
|
if (current && current.endpoint_url && current.model && current.endpoint_id) {
|
||||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2418,7 +2418,7 @@ function initializeEventListeners() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Keys hidden by default on first run (no localStorage yet)
|
// Keys hidden by default on first run (no localStorage yet)
|
||||||
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis']);
|
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||||
|
|
||||||
// Keys that need admin to toggle off (reserved for future use)
|
// Keys that need admin to toggle off (reserved for future use)
|
||||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||||
@@ -2451,6 +2451,8 @@ function initializeEventListeners() {
|
|||||||
applyTextEmojis(state['text-emojis'] === true);
|
applyTextEmojis(state['text-emojis'] === true);
|
||||||
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
|
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
|
||||||
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
|
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
|
||||||
|
// Fullwidth chat toggle (chat-fullwidth: checked=fullwidth, unchecked=big-padding
|
||||||
|
document.body.classList.toggle('fullwidth-chat', state['chat-fullwidth'] === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rearrange toggles in session/model sort dropdowns
|
// Rearrange toggles in session/model sort dropdowns
|
||||||
|
|||||||
@@ -1820,6 +1820,11 @@
|
|||||||
<span class="vis-label">Session Header <span class="vis-hint">Model name & export above chat</span></span>
|
<span class="vis-label">Session Header <span class="vis-hint">Model name & export above chat</span></span>
|
||||||
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
|
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="vis-row">
|
||||||
|
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16"/><path d="M4 10h8"/></svg></span>
|
||||||
|
<span class="vis-label">Full-width chat <span class="vis-hint">Use the full window width (desktop)</span></span>
|
||||||
|
<input type="checkbox" data-ui-key="chat-fullwidth"><span class="vis-switch"></span>
|
||||||
|
</label>
|
||||||
<label class="vis-row">
|
<label class="vis-row">
|
||||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span>
|
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||||
<span class="vis-label">Welcome Message <span class="vis-hint">Logo & tips on empty chat</span></span>
|
<span class="vis-label">Welcome Message <span class="vis-hint">Logo & tips on empty chat</span></span>
|
||||||
@@ -2060,6 +2065,16 @@
|
|||||||
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-card">
|
||||||
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15v3m-3-3h6M12 3v2m0 16v-2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M3 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/><circle cx="12" cy="12" r="3"/></svg>Model Defaults</h2>
|
||||||
|
<div class="admin-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="admin-toggle-label">Share defaults with users</div>
|
||||||
|
<div class="admin-toggle-sub">When on, users without a personal default inherit the global default model (only if those models are allowed for them).</div>
|
||||||
|
</div>
|
||||||
|
<label class="admin-switch"><input type="checkbox" id="adm-shareDefaultsToggle"><span class="admin-slider"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
||||||
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
||||||
|
|||||||
+43
-4
@@ -343,6 +343,28 @@ function initSignupToggle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initShareDefaultsToggle() {
|
||||||
|
const toggle = el('adm-shareDefaultsToggle');
|
||||||
|
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
||||||
|
.catch(e => console.warn('Settings fetch failed:', e));
|
||||||
|
toggle.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ share_defaults_with_users: toggle.checked }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
toggle.checked = !!data.share_defaults_with_users;
|
||||||
|
} catch (e) {
|
||||||
|
toggle.checked = !toggle.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initAddUser() {
|
function initAddUser() {
|
||||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
@@ -1581,8 +1603,8 @@ function initEndpointForm() {
|
|||||||
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
||||||
wrap.appendChild(wp.element);
|
wrap.appendChild(wp.element);
|
||||||
const txt = document.createElement('span');
|
const txt = document.createElement('span');
|
||||||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
txt.textContent = 'Scanning ports 8000-8020, 8080, 1234, 11434, and 11435 for model servers...';
|
||||||
txt.style.cssText = 'opacity:0.7;';
|
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||||
wrap.appendChild(txt);
|
wrap.appendChild(txt);
|
||||||
msg.appendChild(wrap);
|
msg.appendChild(wrap);
|
||||||
discoverBtn._wp = wp;
|
discoverBtn._wp = wp;
|
||||||
@@ -1597,12 +1619,24 @@ function initEndpointForm() {
|
|||||||
} else {
|
} else {
|
||||||
// Auto-add each discovered endpoint. Server dedupes on base_url
|
// Auto-add each discovered endpoint. Server dedupes on base_url
|
||||||
// and returns `existing: true` for already-registered ones.
|
// and returns `existing: true` for already-registered ones.
|
||||||
|
// Map fingerprinted provider IDs to friendly display names.
|
||||||
|
const _PROVIDER_DISPLAY = {
|
||||||
|
llamacpp: 'llama.cpp', lmstudio: 'LM Studio', vllm: 'vLLM',
|
||||||
|
ollama: 'Ollama',
|
||||||
|
};
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||||
|
const providerDisplay = _PROVIDER_DISPLAY[item.provider] || null;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', base);
|
fd.append('base_url', base);
|
||||||
|
if (providerDisplay) {
|
||||||
|
// Use "Provider (host:port)" so the endpoint is immediately
|
||||||
|
// identifiable in the list, e.g. "llama.cpp (localhost:8080)".
|
||||||
|
const hostPart = base.replace(/^https?:\/\//, '').split('/')[0];
|
||||||
|
fd.append('name', `${providerDisplay} (${hostPart})`);
|
||||||
|
}
|
||||||
fd.append('endpoint_kind', 'local');
|
fd.append('endpoint_kind', 'local');
|
||||||
fd.append('model_refresh_mode', 'auto');
|
fd.append('model_refresh_mode', 'auto');
|
||||||
fd.append('skip_probe', 'false');
|
fd.append('skip_probe', 'false');
|
||||||
@@ -1616,7 +1650,12 @@ function initEndpointForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
|
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
|
||||||
const parts = [`Found ${items.length} server${items.length !== 1 ? 's' : ''} with ${totalModels} model${totalModels !== 1 ? 's' : ''}`];
|
const serverNames = items.map(i =>
|
||||||
|
(_PROVIDER_DISPLAY[i.provider] || i.url.replace(/^https?:\/\//, '').split('/')[0])
|
||||||
|
);
|
||||||
|
const parts = [
|
||||||
|
`Found ${items.length} server${items.length !== 1 ? 's' : ''} (${serverNames.join(', ')}) with ${totalModels} model${totalModels !== 1 ? 's' : ''}`,
|
||||||
|
];
|
||||||
if (added) parts.push(`added ${added} new`);
|
if (added) parts.push(`added ${added} new`);
|
||||||
if (skipped) parts.push(`${skipped} already added`);
|
if (skipped) parts.push(`${skipped} already added`);
|
||||||
msg.innerHTML = parts.join(' — ');
|
msg.innerHTML = parts.join(' — ');
|
||||||
@@ -2986,7 +3025,7 @@ function initLogsView() {
|
|||||||
function initAll() {
|
function initAll() {
|
||||||
modalEl = el('settings-modal');
|
modalEl = el('settings-modal');
|
||||||
const inits = [
|
const inits = [
|
||||||
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
|
initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||||
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
||||||
() => settingsModule.initIntegrations()
|
() => settingsModule.initIntegrations()
|
||||||
];
|
];
|
||||||
|
|||||||
+12
-3
@@ -133,11 +133,20 @@ export function providerLabel(endpointUrl) {
|
|||||||
try {
|
try {
|
||||||
host = new URL(endpointUrl).hostname;
|
host = new URL(endpointUrl).hostname;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
|
// Not a full URL (e.g. bare host[:port]) — strip scheme/path best-effort.
|
||||||
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
|
const stripped = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0];
|
||||||
|
const colonIdx = stripped.lastIndexOf(":");
|
||||||
|
host = colonIdx >= 0 ? stripped.slice(0, colonIdx) : stripped;
|
||||||
}
|
}
|
||||||
if (!host) return null;
|
if (!host) return null;
|
||||||
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
const isLoopback = /^(localhost|127\.|0\.0\.0\.0|::1)/.test(host);
|
||||||
|
if (isLoopback) {
|
||||||
|
// Don't name the serving tool from the port — it isn't authoritative
|
||||||
|
// (vLLM/SGLang/llama.cpp share 8000/8080). Discovery identifies the tool by
|
||||||
|
// probing /props and stores the result as the endpoint's name instead.
|
||||||
|
return "Local";
|
||||||
|
}
|
||||||
|
if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
||||||
return "Local";
|
return "Local";
|
||||||
}
|
}
|
||||||
for (const [re, label] of _ENDPOINT_LABELS) {
|
for (const [re, label] of _ENDPOINT_LABELS) {
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ function _showSetupEndpointChoices() {
|
|||||||
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' +
|
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' +
|
||||||
'<div style="margin-top:4px;">or</div>' +
|
'<div style="margin-top:4px;">or</div>' +
|
||||||
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' +
|
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' +
|
||||||
|
'<div style="margin-top:4px;">or llama.cpp (llama-server):</div>' +
|
||||||
|
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:8080/v1</code></pre>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' +
|
'<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' +
|
||||||
'<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' +
|
'<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' +
|
||||||
@@ -238,6 +240,12 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
|
|||||||
text: 'http://llm-host.local:8000/v1',
|
text: 'http://llm-host.local:8000/v1',
|
||||||
copyText: 'http://llm-host.local:8000/v1',
|
copyText: 'http://llm-host.local:8000/v1',
|
||||||
},
|
},
|
||||||
|
{ kind: 'p', text: 'or llama.cpp (llama-server):' },
|
||||||
|
{
|
||||||
|
kind: 'code',
|
||||||
|
text: 'http://localhost:8080/v1',
|
||||||
|
copyText: 'http://localhost:8080/v1',
|
||||||
|
},
|
||||||
{ kind: 'heading', html: SETUP_API_ICON + 'API setup' },
|
{ kind: 'heading', html: SETUP_API_ICON + 'API setup' },
|
||||||
{ kind: 'p', text: 'Paste provider name then API key (example):' },
|
{ kind: 'p', text: 'Paste provider name then API key (example):' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8664,6 +8664,12 @@ button.hamburger {
|
|||||||
/* Hide thinking sections globally via settings toggle */
|
/* Hide thinking sections globally via settings toggle */
|
||||||
body.hide-thinking .thinking-section { display: none !important; }
|
body.hide-thinking .thinking-section { display: none !important; }
|
||||||
|
|
||||||
|
/* Widen chat area via settings toggle */
|
||||||
|
body.fullwidth-chat .chat-history {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-right: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Thinking process styles — colors follow theme accent */
|
/* Thinking process styles — colors follow theme accent */
|
||||||
.msg .body .stream-content {
|
.msg .body .stream-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Test that APIKeyManager.save() uses atomic write to prevent data loss."""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, mock_open
|
||||||
|
from src.api_key_manager import APIKeyManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_creates_atomic_tmp_file(tmp_path):
|
||||||
|
"""Verify save() writes to a temp file and replaces atomically."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-test")
|
||||||
|
|
||||||
|
# The final file should exist with the correct content
|
||||||
|
assert os.path.exists(mgr.api_keys_file)
|
||||||
|
with open(mgr.api_keys_file, "r", encoding="utf-8") as f:
|
||||||
|
keys = json.load(f)
|
||||||
|
assert "openai" in keys
|
||||||
|
|
||||||
|
# The temp file should NOT remain after successful save
|
||||||
|
tmp_file = mgr.api_keys_file + ".tmp"
|
||||||
|
assert not os.path.exists(tmp_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_existing_keys_atomically(tmp_path):
|
||||||
|
"""Verify atomic save doesn't corrupt other providers' keys."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-openai")
|
||||||
|
mgr.save("anthropic", "sk-anthropic")
|
||||||
|
|
||||||
|
loaded = mgr.load()
|
||||||
|
assert loaded["openai"] == "sk-openai"
|
||||||
|
assert loaded["anthropic"] == "sk-anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_original_on_write_failure(tmp_path):
|
||||||
|
"""If the temp file write fails, the original keys file must survive intact."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-original")
|
||||||
|
|
||||||
|
# Now attempt a save that will fail during json.dump
|
||||||
|
with patch("builtins.open", side_effect=OSError("disk full")):
|
||||||
|
with pytest.raises(OSError, match="disk full"):
|
||||||
|
mgr.save("anthropic", "sk-new")
|
||||||
|
|
||||||
|
# Original file must still be intact with the original key
|
||||||
|
loaded = mgr.load()
|
||||||
|
assert loaded == {"openai": "sk-original"}
|
||||||
|
assert "anthropic" not in loaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cleans_up_tmp_on_failure(tmp_path):
|
||||||
|
"""Temp file should be removed if the write fails."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-original")
|
||||||
|
|
||||||
|
tmp_file = mgr.api_keys_file + ".tmp"
|
||||||
|
|
||||||
|
# Force a failure after the temp file is opened
|
||||||
|
original_open = open
|
||||||
|
|
||||||
|
def failing_open(*args, **kwargs):
|
||||||
|
f = original_open(*args, **kwargs)
|
||||||
|
if args and isinstance(args[0], str) and args[0].endswith(".tmp"):
|
||||||
|
# Close the file then raise
|
||||||
|
f.close()
|
||||||
|
raise OSError("simulated write failure")
|
||||||
|
return f
|
||||||
|
|
||||||
|
with patch("builtins.open", side_effect=failing_open):
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
mgr.save("anthropic", "sk-new")
|
||||||
|
|
||||||
|
# Temp file should be cleaned up
|
||||||
|
assert not os.path.exists(tmp_file)
|
||||||
|
|
||||||
|
# Original should be intact
|
||||||
|
loaded = mgr.load()
|
||||||
|
assert loaded == {"openai": "sk-original"}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for llama.cpp (llama-server) local discovery: the default scan list
|
||||||
|
includes llama-server's port 8080, and `_fingerprint_provider` identifies a
|
||||||
|
llama-server via its native ``/props`` endpoint without misfiring on LM Studio,
|
||||||
|
Ollama, or plain OpenAI-compatible servers.
|
||||||
|
|
||||||
|
Companion to test_lmstudio_discovery.py; the llama.cpp fingerprint is checked
|
||||||
|
*after* the LM Studio one, so LM Studio still wins when both could match.
|
||||||
|
"""
|
||||||
|
from src.model_discovery import ModelDiscovery
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload, ok=True):
|
||||||
|
self._payload = payload
|
||||||
|
self.is_success = ok
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# discover_models — scan list includes 8080 (llama-server default)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestLlamaCppScanPort:
|
||||||
|
def test_discover_models_scans_port_8080(self, monkeypatch):
|
||||||
|
"""llama-server's default port 8080 must be among the scan targets."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
scanned_ports = []
|
||||||
|
|
||||||
|
def fake_check_port(host, port):
|
||||||
|
scanned_ports.append(port)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(discovery, "_check_port", fake_check_port)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.model_discovery.discover_tailscale_hosts", lambda: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
discovery.discover_models()
|
||||||
|
assert 8080 in scanned_ports
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# _fingerprint_provider — llama-server via /props
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestLlamaCppFingerprint:
|
||||||
|
# A representative llama-server /props payload (trimmed to the keys the
|
||||||
|
# fingerprint relies on).
|
||||||
|
LLAMACPP_PROPS = {
|
||||||
|
"default_generation_settings": {"n_ctx": 4096, "temperature": 0.8},
|
||||||
|
"total_slots": 1,
|
||||||
|
"chat_template": "{{ messages }}",
|
||||||
|
"model_path": "/models/gemma-4-12b-it-Q4_K_M.gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_llamacpp_props_detected(self, monkeypatch):
|
||||||
|
"""A server that isn't LM Studio but answers /props as llama-server →
|
||||||
|
'llamacpp'."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
# OpenAI-compatible shape, not the LM Studio native shape.
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse(self.LLAMACPP_PROPS)
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) == "llamacpp"
|
||||||
|
|
||||||
|
def test_lmstudio_still_wins_when_both_match(self, monkeypatch):
|
||||||
|
"""If /api/v1/models reports the LM Studio native shape, LM Studio is
|
||||||
|
returned even when /props would also match."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
lmstudio_native = {
|
||||||
|
"models": [{"type": "llm", "key": "qwen3.6-27b",
|
||||||
|
"architecture": "qwen35", "format": "gguf"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse(lmstudio_native)
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse(self.LLAMACPP_PROPS)
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) == "lmstudio"
|
||||||
|
|
||||||
|
def test_props_without_llamacpp_keys_not_detected(self, monkeypatch):
|
||||||
|
"""A /props-style response lacking llama-server marker keys → None."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({"data": []})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse({"unrelated": "value"})
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) is None
|
||||||
|
|
||||||
|
def test_props_unreachable_returns_none(self, monkeypatch):
|
||||||
|
"""No /api/v1/models and a failing /props → None (not an exception)."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
raise OSError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) is None
|
||||||
|
|
||||||
|
def test_check_port_attaches_llamacpp_provider(self, monkeypatch):
|
||||||
|
"""End-to-end: _check_port tags a discovered llama-server as 'llamacpp'."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/v1/models"):
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse(self.LLAMACPP_PROPS)
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
result = discovery._check_port("localhost", 8080)
|
||||||
|
assert result is not None
|
||||||
|
assert result["provider"] == "llamacpp"
|
||||||
|
assert result["models"] == ["gemma-4-12b"]
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Docker loopback rewrite — host.docker.internal:8080 in scan
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestDockerLoopbackScan:
|
||||||
|
def test_host_docker_internal_in_scan_hosts(self, monkeypatch):
|
||||||
|
"""When no LLM_HOSTS env override is set, host.docker.internal must be
|
||||||
|
included in the scan host list so llama-server on the Docker host is
|
||||||
|
discovered from inside the container."""
|
||||||
|
monkeypatch.delenv("LLM_HOSTS", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.model_discovery.discover_tailscale_hosts", lambda: [],
|
||||||
|
)
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
hosts = discovery._get_hosts()
|
||||||
|
assert "host.docker.internal" in hosts
|
||||||
|
|
||||||
|
def test_discovered_endpoint_url_uses_provided_host(self, monkeypatch):
|
||||||
|
"""When host.docker.internal:8080 is probed, the returned base_url
|
||||||
|
contains host.docker.internal — not a rewritten 127.0.0.1."""
|
||||||
|
from src.model_discovery import ModelDiscovery as _MD
|
||||||
|
|
||||||
|
discovery = _MD(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/v1/models") or url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse({
|
||||||
|
"default_generation_settings": {"n_ctx": 4096},
|
||||||
|
"total_slots": 1,
|
||||||
|
"chat_template": "{{ messages }}",
|
||||||
|
})
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
result = discovery._check_port("host.docker.internal", 8080)
|
||||||
|
assert result is not None
|
||||||
|
assert "host.docker.internal" in result["url"]
|
||||||
|
assert "127.0.0.1" not in result["url"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
|
||||||
|
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_fenced_read_file_function_call_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file("notes/todo.md")\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert blocks[0].content == "notes/todo.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_fenced_read_file_function_call_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```python\nread_file(path="notes/todo.md", offset=3, limit=2)\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert json.loads(blocks[0].content) == {
|
||||||
|
"path": "notes/todo.md",
|
||||||
|
"offset": 3,
|
||||||
|
"limit": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_fenced_read_file_command_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file "notes/todo.md"\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert blocks[0].content == "notes/todo.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_fenced_read_file_json_command_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file {"path":"notes/todo.md","offset":1,"limit":4}\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert json.loads(blocks[0].content) == {
|
||||||
|
"path": "notes/todo.md",
|
||||||
|
"offset": 1,
|
||||||
|
"limit": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_bash_read_file_block_stays_bash():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file notes/todo.md\necho done\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "bash"
|
||||||
|
assert "read_file notes/todo.md" in blocks[0].content
|
||||||
|
|
||||||
|
|
||||||
|
def test_nontrivial_python_read_file_name_stays_python_code():
|
||||||
|
blocks = parse_tool_blocks('```python\nprint(read_file("notes/todo.md"))\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_tool_blocks_removes_rescued_read_file_fence():
|
||||||
|
text = 'Opening file:\n```bash\nread_file "notes/todo.md"\n```\nDone.'
|
||||||
|
|
||||||
|
cleaned = strip_tool_blocks(text)
|
||||||
|
|
||||||
|
assert "```" not in cleaned
|
||||||
|
assert "read_file" not in cleaned
|
||||||
|
assert "Opening file:" in cleaned
|
||||||
|
assert "Done." in cleaned
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for share_defaults_with_users setting"""
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from tests.helpers.import_state import preserve_import_state
|
||||||
|
from tests.helpers.db_stubs import make_core_db_stub
|
||||||
|
|
||||||
|
with preserve_import_state("core.database", "src.database", "routes.model_routes", "routes.prefs_routes"):
|
||||||
|
import routes.model_routes as model_routes
|
||||||
|
import routes.prefs_routes as prefs_routes
|
||||||
|
import src.auth_helpers as auth_helpers
|
||||||
|
|
||||||
|
|
||||||
|
### Helper Classes
|
||||||
|
|
||||||
|
class _FakeEndpoint:
|
||||||
|
"""Minimal fake endpoint for testing"""
|
||||||
|
def __init__(self, id, base_url, is_enabled=True, owner=None):
|
||||||
|
self.id = id
|
||||||
|
self.base_url = base_url
|
||||||
|
self.is_enabled = is_enabled
|
||||||
|
self.owner = owner
|
||||||
|
self.cached_models = None
|
||||||
|
self.hidden_models = None
|
||||||
|
self.pinned_models = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
"""Fake query object for testing"""
|
||||||
|
def __init__(self, endpoints, user=None, include_shared=True):
|
||||||
|
self._endpoints = endpoints
|
||||||
|
self._user = user
|
||||||
|
self._include_shared = include_shared
|
||||||
|
|
||||||
|
def filter(self, *conditions):
|
||||||
|
for cond in conditions:
|
||||||
|
cond_str = str(cond)
|
||||||
|
print(f"Filter condition: {cond_str}")
|
||||||
|
if 'owner' in cond_str and 'IS NULL' not in cond_str:
|
||||||
|
self._include_shared = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
"""Return first endpoint respecting owner filter"""
|
||||||
|
if not self._endpoints:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._user:
|
||||||
|
for ep in self._endpoints:
|
||||||
|
ep_owner = getattr(ep, 'owner', None)
|
||||||
|
if ep_owner == self._user:
|
||||||
|
return ep
|
||||||
|
if self._include_shared and ep_owner is None:
|
||||||
|
return ep
|
||||||
|
return None
|
||||||
|
return self._endpoints[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_session(endpoints, user=None):
|
||||||
|
"""Create a fake DB session that returns our fake query"""
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_query = _FakeQuery(endpoints, user)
|
||||||
|
fake_session.query.return_value = fake_query
|
||||||
|
return fake_session
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_chat_route(router):
|
||||||
|
"""Extract the /api/default-chat GET route from the router"""
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == "/api/default-chat" and "GET" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("GET /api/default-chat route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(user=None, auth_manager=None):
|
||||||
|
"""Create a fake request for testing"""
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(current_user=user),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
||||||
|
client=SimpleNamespace(host="127.0.0.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
### Shared test logic
|
||||||
|
def _run_get_default_chat_test(monkeypatch, share_defaults_enabled, second_endpoint_only=False):
|
||||||
|
"""Helper function that runs get_default_chat with the given share_defaults_with_users setting."""
|
||||||
|
|
||||||
|
global_settings = {
|
||||||
|
"default_endpoint_id": "global-ep-123",
|
||||||
|
"default_model": "qwen-3.6",
|
||||||
|
"default_model_fallbacks": [
|
||||||
|
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
|
||||||
|
],
|
||||||
|
"share_defaults_with_users": share_defaults_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_load_settings", lambda: global_settings)
|
||||||
|
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: {})
|
||||||
|
|
||||||
|
fake_auth_manager = MagicMock()
|
||||||
|
fake_auth_manager.is_admin = lambda user: False
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
_FakeEndpoint(
|
||||||
|
id="global-ep-123",
|
||||||
|
base_url="http://global-endpoint:8000/v1",
|
||||||
|
is_enabled=True
|
||||||
|
),
|
||||||
|
_FakeEndpoint(
|
||||||
|
id="fallback-ep",
|
||||||
|
base_url="http://fallback-endpoint:8000/v1",
|
||||||
|
is_enabled=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# When testing fallback scenario, removes the primary endpoint
|
||||||
|
if second_endpoint_only:
|
||||||
|
endpoints = [endpoints[1]]
|
||||||
|
|
||||||
|
fake_db = _make_db_session(endpoints, user="regular_user")
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
|
||||||
|
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
|
||||||
|
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
|
||||||
|
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
get_default_chat = _get_default_chat_route(router)
|
||||||
|
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
|
||||||
|
|
||||||
|
result = get_default_chat(fake_request)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
### Test Functions
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to empty
|
||||||
|
ep_id, model, and fallbacks when share_defaults_with_users is disabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=False)
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "", "Should get empty endpoint_id"
|
||||||
|
assert test_data["model"] == "", "Should get empty model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to global
|
||||||
|
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
|
||||||
|
|
||||||
|
assert test_data["model"] == "qwen-3.6", \
|
||||||
|
"model should be resolved from global default_model"
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "global-ep-123", \
|
||||||
|
"Should get global endpoint_id"
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to global
|
||||||
|
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
|
||||||
|
|
||||||
|
assert test_data["model"] == "qwen-3.6", \
|
||||||
|
"model should be resolved from global default_model"
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "fallback-ep", \
|
||||||
|
"Should get global endpoint_id"
|
||||||
@@ -93,10 +93,19 @@ class TestProviderLabel:
|
|||||||
def test_known_labels(self, url, expected):
|
def test_known_labels(self, url, expected):
|
||||||
assert _provider_label(url) == expected
|
assert _provider_label(url) == expected
|
||||||
|
|
||||||
def test_local_non_ollama_endpoint(self):
|
@pytest.mark.parametrize("url", [
|
||||||
# A loopback host that isn't on the native Ollama /api path is just a
|
"http://localhost:8080/v1",
|
||||||
# generic local endpoint (e.g. an OpenAI-compatible local server).
|
"http://127.0.0.1:8080/v1",
|
||||||
assert _provider_label("http://localhost:8080/v1") == "local endpoint"
|
"http://localhost:8000/v1",
|
||||||
|
"http://localhost:1234/v1",
|
||||||
|
"http://localhost:9999/v1",
|
||||||
|
])
|
||||||
|
def test_local_non_ollama_endpoint(self, url):
|
||||||
|
# The serving tool is NOT inferred from the port: vLLM, SGLang, llama.cpp
|
||||||
|
# and plain OpenAI-compatible servers all share 8000/8080, so a port-only
|
||||||
|
# label would mislabel real setups. The tool is identified by /props
|
||||||
|
# fingerprinting during discovery; this helper stays neutral.
|
||||||
|
assert _provider_label(url) == "local endpoint"
|
||||||
|
|
||||||
def test_unknown_host_returns_host(self):
|
def test_unknown_host_returns_host(self):
|
||||||
assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example"
|
assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""providerLabel() in providers.js must NOT name the serving tool from the port,
|
||||||
|
mirroring the Python _provider_label() in src/llm_core.py.
|
||||||
|
|
||||||
|
A port is not authoritative: vLLM, SGLang, llama.cpp and plain OpenAI-compatible
|
||||||
|
servers all routinely share 8000/8080, so a port-only label would mislabel real
|
||||||
|
setups (e.g. a vLLM box on :8080 shown as "llama.cpp"). The actual tool is
|
||||||
|
identified by probing /props during discovery and stored as the endpoint's name.
|
||||||
|
The rule here: loopback → "Local"; private-LAN IPs → "Local"; known remote
|
||||||
|
provider hosts → their provider name.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_SRC = _REPO / "static" / "js" / "providers.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_label(url: str) -> str | None:
|
||||||
|
src = _SRC.read_text(encoding="utf-8")
|
||||||
|
# Strip the `export` keyword so the module runs standalone.
|
||||||
|
src_runnable = src.replace("export function providerLabel", "function providerLabel")
|
||||||
|
src_runnable = src_runnable.replace("export default {", "const _default = {")
|
||||||
|
js = src_runnable + f"\nconsole.log(JSON.stringify(providerLabel({json.dumps(url)})));"
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, encoding="utf-8",
|
||||||
|
cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
@pytest.mark.parametrize("url,expected", [
|
||||||
|
# Loopback never names the tool from the port — it isn't authoritative.
|
||||||
|
("http://localhost:8080/v1", "Local"),
|
||||||
|
("http://127.0.0.1:8080/v1", "Local"),
|
||||||
|
("http://localhost:8000/v1", "Local"),
|
||||||
|
("http://localhost:1234/v1", "Local"),
|
||||||
|
("http://localhost:11434/api", "Local"),
|
||||||
|
("http://localhost:9999/v1", "Local"),
|
||||||
|
# Known remote provider hosts are still labeled by host suffix.
|
||||||
|
("https://api.openai.com/v1", "OpenAI"),
|
||||||
|
("https://api.groq.com/openai/v1","Groq"),
|
||||||
|
("http://192.168.1.50:8080", "Local"), # private LAN: no port branding
|
||||||
|
])
|
||||||
|
def test_provider_label_neutral_for_loopback(url, expected):
|
||||||
|
assert _provider_label(url) == expected
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -23,3 +24,49 @@ def test_create_default_admin_normalizes_env_username(tmp_path, monkeypatch):
|
|||||||
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||||
assert "adminuser" in data["users"]
|
assert "adminuser" in data["users"]
|
||||||
assert "AdminUser" not in data["users"]
|
assert "AdminUser" not in data["users"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_loads_admin_password_from_env_file(tmp_path, monkeypatch):
|
||||||
|
"""Regression: setup.py must honor an admin password pre-seeded in .env on
|
||||||
|
native installs, even when the var is not exported into the shell
|
||||||
|
(docs/setup.md documents this). Previously setup.py never called
|
||||||
|
load_dotenv(), so os.getenv() saw nothing and a random password was
|
||||||
|
generated instead."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
setup_module = _load_setup_module()
|
||||||
|
|
||||||
|
# Credentials live ONLY in a .env beside setup.py (written with a UTF-8 BOM,
|
||||||
|
# the Notepad-on-Windows case that utf-8-sig must tolerate) — not exported.
|
||||||
|
monkeypatch.delenv("ODYSSEUS_ADMIN_USER", raising=False)
|
||||||
|
monkeypatch.delenv("ODYSSEUS_ADMIN_PASSWORD", raising=False)
|
||||||
|
(tmp_path / ".env").write_text(
|
||||||
|
"ODYSSEUS_ADMIN_USER=presetuser\nODYSSEUS_ADMIN_PASSWORD=fromenvfile12345\n",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Point setup at the temp dir and neutralize main()'s heavy steps.
|
||||||
|
monkeypatch.setattr(setup_module, "BASE_DIR", str(tmp_path))
|
||||||
|
auth_path = tmp_path / "auth.json"
|
||||||
|
monkeypatch.setattr(setup_module, "AUTH_FILE", str(auth_path))
|
||||||
|
monkeypatch.setattr(setup_module, "check_arch", lambda: None)
|
||||||
|
monkeypatch.setattr(setup_module, "create_dirs", lambda: None)
|
||||||
|
monkeypatch.setattr(setup_module, "create_env", lambda: None)
|
||||||
|
monkeypatch.setattr(setup_module, "check_deps", lambda: None)
|
||||||
|
monkeypatch.setattr(setup_module, "init_database", lambda: None)
|
||||||
|
# Force the non-interactive branch so the test never blocks on a prompt.
|
||||||
|
monkeypatch.setenv("ODYSSEUS_SKIP_ADMIN_PROMPT", "1")
|
||||||
|
|
||||||
|
try:
|
||||||
|
setup_module.main()
|
||||||
|
finally:
|
||||||
|
# load_dotenv writes real os.environ entries; undo so sibling tests
|
||||||
|
# don't inherit them.
|
||||||
|
os.environ.pop("ODYSSEUS_ADMIN_USER", None)
|
||||||
|
os.environ.pop("ODYSSEUS_ADMIN_PASSWORD", None)
|
||||||
|
|
||||||
|
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||||
|
assert "presetuser" in data["users"], data
|
||||||
|
assert bcrypt.checkpw(
|
||||||
|
b"fromenvfile12345", data["users"]["presetuser"]["password_hash"].encode()
|
||||||
|
), "admin password from .env was ignored; a random one was generated"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""The /setup guide must offer a llama.cpp (llama-server) local example.
|
||||||
|
|
||||||
|
Without it, the port-8080 "llama.cpp" provider label (src/llm_core.py
|
||||||
|
_provider_label) is never reachable from first-run setup — a user pasting a
|
||||||
|
local endpoint only saw the Ollama and generic examples. Both the static-HTML
|
||||||
|
and the streamed-blocks renderings of the setup guide must carry the example.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SRC = Path(__file__).resolve().parent.parent / "static" / "js" / "slashCommands.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_guide_offers_llamacpp_local_example():
|
||||||
|
src = _SRC.read_text(encoding="utf-8")
|
||||||
|
# The example URL appears in both the HTML-string and streamed renderings.
|
||||||
|
assert src.count("http://localhost:8080/v1") >= 2
|
||||||
|
assert "llama.cpp (llama-server)" in src
|
||||||
Reference in New Issue
Block a user