Compare commits

...
10 Commits
Author SHA1 Message Date
Joel Alejandro Escareño Fernández e0ccf250a4 feat(discovery): detect llama.cpp servers and label local providers (#4729)
* feat(discovery): detect llama.cpp servers and label local providers

Scan port 8080 (llama-server) and 11435 (APFEL) during discovery, fingerprint
llama.cpp via its native /props endpoint, and label well-known local serving
ports (8080 llama.cpp, 8000 vLLM, 1234 LM Studio, 11434 Ollama) consistently
in both the Python provider helper and the JS endpoint UI. Adds a llama.cpp
hint to the /setup slash command.

* fix(discovery): don't infer the serving tool from the port alone

Per review: vLLM, SGLang, llama.cpp and plain OpenAI-compatible servers all
share 8000/8080, so labeling by port mislabels real setups (a vLLM box on 8080
shown as llama.cpp). Drop the port->tool assertions from _provider_label and
providerLabel; the authoritative signal is the /props fingerprint done during
discovery, which is unchanged. Loopback now reads a neutral 'local endpoint' /
'Local'. Tests updated to assert the neutral labels.
2026-06-23 23:39:56 +02:00
Michaelandmichaelxer 72c0bde8a9 fix: use atomic write in APIKeyManager.save() to prevent credential data loss (#4591) (#4597)
* fix: use atomic write in APIKeyManager.save() to prevent data loss

Opening api_keys.json with 'w' truncates the file before writing, so a
crash, disk-full, or mid-write error leaves all stored provider API keys
corrupted. Switch to atomic write (temp file + fsync + os.replace) so
the original file is always intact on any failure.

Fixes #4591

* chore: trigger CI re-run

* chore: update PR description

* chore: fix how-to-test section for description check

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-23 23:28:53 +02:00
Dividesbyzer0 2e16394b41 fix(agent): parse misfenced read_file calls (#4799) 2026-06-23 23:20:13 +02:00
Jakub Grula 060dbf0681 feat: Allow admins to choose if they want to share defaults (#4752)
* First bare fix

* Adding the option toggle

* toggle function fix

* Final fix, added missing /auth/

* Extended toggle text & added tests

* Comments change

* Description toggle change

* br tag fix

* description change based on suggestion
2026-06-23 23:06:45 +02:00
Skoh d9ad418195 feat(ui): add toggle for padding around chat area (#4691) 2026-06-23 22:20:17 +02:00
Rudra Sarker 08994a0a96 fix: email poller marks calendar extraction processed on LLM failure (#4622)
Move calendar processed-marker insert into the LLM success path (else branch).
Previously, the INSERT ran even after a transient LLM failure, causing the
poller to skip retrying calendar extraction on subsequent runs.

Minimal change: only touches the try/except/else control flow in
_auto_summarize_pass_single() — preserves existing formatting and line endings.
2026-06-23 20:32:30 +02:00
Solanki SumitandClaude Opus 4.8 e9136f801a fix(setup): load .env so a pre-seeded admin password is honored on native installs (#4787)
setup.py read ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD via os.getenv()
but never loaded .env, so on native Linux/macOS installs a password
pre-seeded in .env (documented in docs/setup.md and .env.example) was
silently ignored and a random one generated, breaking the first login.
Docker was unaffected because compose passes the vars into the container env.

Call load_dotenv(BASE_DIR/.env, encoding="utf-8-sig") at the top of main(),
mirroring app.py (utf-8-sig tolerates a Notepad UTF-8 BOM). load_dotenv does
not override already-exported OS vars, so the existing precedence is kept.
python-dotenv is already a required dependency.

Adds a regression test that pre-seeds credentials only in .env (not the
shell) and asserts the stored bcrypt hash matches the pre-seeded password.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:08:05 +02:00
Ahmed DlshadandClaude Opus 4.8 e90dbc1012 fix(routes): 500 (not 404) when the app-shell index.html is missing (#4791)
Follow-up to #4637. serve_index — the handler for / and the SPA deep-link
routes (/notes, /calendar, /cookbook, /email, /memory, /gallery, /tasks,
/library) — pre-checked os.path.exists and raised its own
HTTPException(404, "index.html not found") when the bundle was missing. So a
missing core template returned 404 before serve_html_with_nonce's 500 could
fire, the one inconsistency left after #4637.

index.html is a fixed, app-bundled template; a missing one is a broken
deployment (server fault), not a client "not found", so it should surface as a
logged 500 in 5xx alerting rather than a 404. Keep the static->root fallback,
drop the redundant existence guard and the dead-end 404, and let the shared
helper handle the missing case.

Verified against the running app: / and /notes return 200 with the bundle
present and a logged 500 when index.html is absent.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:47:22 +02:00
Pedro BarbosaandClaude Opus 4.8 d47715036a fix: Real-ESRGAN install + Cookbook deps-panel crash on the Python 3.14 image (#4694)
* fix(docker): make Real-ESRGAN installable on the Python 3.14 image

realesrgan's deps basicsr/gfpgan/facexlib (unmaintained since 2022) read
their version in setup.py via `exec(...); locals()['__version__']`, which
raises KeyError on Python 3.13+ — PEP 667 made locals() in a function an
independent snapshot that exec() can no longer mutate. That fails the
Cookbook "install realesrgan" sdist build on the python:3.14 base.

Add a `realesrgan-wheels` builder stage that fetches the pinned sdists,
patches get_version() to exec into an explicit namespace dict, and builds
wheels; the final stage installs them --no-deps so a later
`pip install realesrgan` resolves from wheels instead of rebuilding the
broken sdists. torch stays a runtime pull to keep the base image lean.

Also add the runtime libs opencv-python (cv2) needs — libgl1,
libglib2.0-0t64, libxcb1 — which the slim base omits; without them the
install succeeds but `import cv2` dies with
`libxcb.so.1: cannot open shared object file`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cookbook): don't let a package's sys.exit() on import hang the deps panel

The local optional-dependency probe imports each package in-process and
catches ImportError / Exception. But a package can call sys.exit() at
import time — e.g. rembg does `sys.exit(1)` when no onnxruntime backend
loads. SystemExit is a BaseException, not Exception, so it escaped the
probe, propagated out of the list_packages endpoint, and hung the whole
Dependencies panel / worker (the UI loads forever).

Catch (Exception, SystemExit) so one broken optional package is reported
as not-usable instead of taking down the panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:31:00 +02:00
Kalin Stoyanov 87407b3a09 fix debugging on windows (#4679) 2026-06-23 18:44:05 +02:00
26 changed files with 1013 additions and 43 deletions
+30
View File
@@ -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
# 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 \
openssh-client \
gosu \
libgl1 \
libglib2.0-0t64 \
libxcb1 \
&& 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
# /var/run/docker.sock mount). The Debian `docker.io` package ships
# 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 \
&& 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 . .
+16 -4
View File
@@ -2,6 +2,16 @@
import mimetypes
import os
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:
@@ -796,10 +806,12 @@ async def serve_index(request: Request):
static_path = abs_join(BASE_DIR, "static/index.html")
if os.path.exists(static_path):
return serve_html_with_nonce(request, static_path)
root_path = abs_join(BASE_DIR, "index.html")
if os.path.exists(root_path):
return serve_html_with_nonce(request, root_path)
raise HTTPException(404, "index.html not found")
# No static bundle — fall back to a root-level index.html if one is shipped.
# If neither exists, serve_html_with_nonce logs it and returns a generic 500:
# a missing index.html is a broken deployment (server fault), not a client
# "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")
async def serve_notes(request: Request):
+70
View File
@@ -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
View File
@@ -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}")
except Exception as 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
try:
_cc = _sql3.connect(SCHEDULED_DB)
_cc.execute(
"INSERT OR REPLACE INTO email_calendar_extractions "
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
_cal_run_count, datetime.utcnow().isoformat())
)
_cc.commit()
_cc.close()
_cal_existing.add(message_id)
except Exception as ce:
logger.debug(f"Could not cache calendar extraction: {ce}")
else:
# Record we processed this email so we don't re-LLM next run.
# Only mark as processed on success ? transient LLM failures
# are retried on the next poll run (matches summary/reply pattern).
try:
_cc = _sql3.connect(SCHEDULED_DB)
_cc.execute(
"INSERT OR REPLACE INTO email_calendar_extractions "
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
_cal_run_count, datetime.utcnow().isoformat())
)
_cc.commit()
_cc.close()
_cal_existing.add(message_id)
except Exception as ce:
logger.debug(f"Could not cache calendar extraction: {ce}")
if need_urgent:
try:
+10
View File
@@ -2108,6 +2108,16 @@ def setup_model_routes(model_discovery):
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
model = (_user_prefs.get("default_model") or "").strip()
_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:
ep_id = settings.get("default_endpoint_id", "")
model = settings.get("default_model", "")
+8 -3
View File
@@ -1377,11 +1377,16 @@ def setup_shell_routes() -> APIRouter:
pkg["installed"] = False
except importlib_metadata.PackageNotFoundError:
pkg["installed"] = False
except Exception:
except (Exception, SystemExit):
# Installed but crashes on import — e.g. a CUDA build of
# llama-cpp-python raising FileNotFoundError when the CUDA
# toolkit dir is absent. One broken optional package must not
# 500 the entire packages panel; report it as not usable.
# toolkit dir is absent, or rembg calling sys.exit(1) when no
# 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
# llama_cpp partial-state probe: when the package is installed
+9
View File
@@ -239,6 +239,15 @@ def check_arch():
def main():
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
# Silicon under an x86/Rosetta Python) before importing anything native.
check_arch()
+17 -2
View File
@@ -81,11 +81,26 @@ class APIKeyManager:
keys stay encrypted. Loading via load() first would decrypt them and
write them back as plaintext, which then fails to decrypt on the next
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[provider] = self.encrypt_api_key(api_key)
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
json.dump(keys, f)
tmp_file = self.api_keys_file + ".tmp"
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]:
"""Load and decrypt API keys"""
+8 -1
View File
@@ -777,10 +777,17 @@ def _provider_label(url: str) -> str:
pass
if _is_ollama_native_url(url): return "Ollama"
try:
host = (urlparse(url).hostname or "").lower()
_parsed_local = urlparse(url)
host = (_parsed_local.hostname or "").lower()
port = _parsed_local.port
except Exception:
return "provider"
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 host or "provider"
+20 -4
View File
@@ -163,6 +163,21 @@ class ModelDiscovery:
return "lmstudio"
except Exception:
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
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}")
# Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook),
# 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is
# occupied by Ollama. The env vars can add more ports which will be merged in.
ports = list(range(8000, 8021)) + [1234, 11434, 11435]
# Well-known ports: 8000-8020 (vLLM, SGLang, Cookbook), 8080 (llama.cpp /
# llama-server default), 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL
# as its default port is occupied by Ollama. The env vars can add more
# 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]
targets = [(h, p) for h in hosts for p in ports]
+4
View File
@@ -141,6 +141,10 @@ DEFAULT_SETTINGS = {
# before producing output (endpoint offline / errors), the chat
# dispatch retries the next entry in order.
"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_model": "",
# Ordered fallback chain for the Utility model (summarization, naming,
+84 -1
View File
@@ -308,6 +308,88 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
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]:
if isinstance(value, str) and 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.
continue
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:
blocks.append(block)
continue
+5 -3
View File
@@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() {
if (!sessionModule) return false;
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);
return true;
}
@@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() {
const sessions = sessionModule.getSessions();
const currentId = sessionModule.getCurrentSessionId();
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);
return true;
}
@@ -2418,7 +2418,7 @@ function initializeEventListeners() {
};
// 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)
const UI_VIS_ADMIN_ONLY = new Set([]);
@@ -2451,6 +2451,8 @@ function initializeEventListeners() {
applyTextEmojis(state['text-emojis'] === true);
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
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
+15
View File
@@ -1820,6 +1820,11 @@
<span class="vis-label">Session Header <span class="vis-hint">Model name &amp; export above chat</span></span>
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
</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">
<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 &amp; 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>
</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">
<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>
+43 -4
View File
@@ -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() {
fetch('/api/auth/policy', { credentials: 'same-origin' })
.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.appendChild(wp.element);
const txt = document.createElement('span');
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
txt.style.cssText = 'opacity:0.7;';
txt.textContent = 'Scanning ports 8000-8020, 8080, 1234, 11434, and 11435 for model servers...';
txt.style.cssText = 'font-size:12px;opacity:0.7;';
wrap.appendChild(txt);
msg.appendChild(wrap);
discoverBtn._wp = wp;
@@ -1597,12 +1619,24 @@ function initEndpointForm() {
} else {
// Auto-add each discovered endpoint. Server dedupes on base_url
// 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 skipped = 0;
for (const item of items) {
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
const providerDisplay = _PROVIDER_DISPLAY[item.provider] || null;
const fd = new FormData();
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('model_refresh_mode', 'auto');
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 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 (skipped) parts.push(`${skipped} already added`);
msg.innerHTML = parts.join(' — ');
@@ -2986,7 +3025,7 @@ function initLogsView() {
function initAll() {
modalEl = el('settings-modal');
const inits = [
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm,
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
() => settingsModule.initIntegrations()
];
+12 -3
View File
@@ -133,11 +133,20 @@ export function providerLabel(endpointUrl) {
try {
host = new URL(endpointUrl).hostname;
} catch (_) {
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
// Not a full URL (e.g. bare host[:port]) — strip scheme/path best-effort.
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 (/^(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";
}
for (const [re, label] of _ENDPOINT_LABELS) {
+8
View File
@@ -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>' +
'<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>' +
'<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 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>' +
@@ -238,6 +240,12 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
text: '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: 'p', text: 'Paste provider name then API key (example):' },
{
+6
View File
@@ -8664,6 +8664,12 @@ button.hamburger {
/* Hide thinking sections globally via settings toggle */
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 */
.msg .body .stream-content {
width: 100%;
+79
View File
@@ -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"}
+178
View File
@@ -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
+173
View File
@@ -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"
+13 -4
View File
@@ -93,10 +93,19 @@ class TestProviderLabel:
def test_known_labels(self, url, expected):
assert _provider_label(url) == expected
def test_local_non_ollama_endpoint(self):
# A loopback host that isn't on the native Ollama /api path is just a
# generic local endpoint (e.g. an OpenAI-compatible local server).
assert _provider_label("http://localhost:8080/v1") == "local endpoint"
@pytest.mark.parametrize("url", [
"http://localhost:8080/v1",
"http://127.0.0.1:8080/v1",
"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):
assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example"
+54
View File
@@ -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
+47
View File
@@ -1,5 +1,6 @@
import importlib.util
import json
import os
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"))
assert "adminuser" 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"
+17
View File
@@ -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