mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Compare commits
14 Commits
e8175c9535
...
5ce2056521
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce2056521 | |||
| e0ccf250a4 | |||
| 72c0bde8a9 | |||
| 2e16394b41 | |||
| 060dbf0681 | |||
| d9ad418195 | |||
| 08994a0a96 | |||
| e9136f801a | |||
| e90dbc1012 | |||
| d47715036a | |||
| 87407b3a09 | |||
| 119228a6db | |||
| 8f5e36a079 | |||
| 30dd789351 |
+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
|
||||
|
||||
# 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 . .
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -44,7 +54,7 @@ from typing import Dict
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -65,7 +75,7 @@ from core.exceptions import (
|
||||
|
||||
import bcrypt as _bcrypt
|
||||
|
||||
from src.app_helpers import abs_join
|
||||
from src.app_helpers import abs_join, serve_html_with_nonce
|
||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
@@ -791,23 +801,17 @@ app.include_router(setup_companion_routes())
|
||||
|
||||
# ========= ROUTES (kept in app.py) =========
|
||||
|
||||
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
nonce = getattr(request.state, "csp_nonce", "")
|
||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@app.get("/")
|
||||
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")
|
||||
return serve_html_with_nonce(request, static_path)
|
||||
# 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):
|
||||
@@ -848,13 +852,13 @@ async def serve_library(request: Request):
|
||||
@app.get("/backgrounds")
|
||||
async def serve_backgrounds(request: Request):
|
||||
"""Sandbox page for prototyping background effects. No auth required."""
|
||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||
|
||||
@app.get("/login")
|
||||
async def serve_login(request: Request):
|
||||
if not AUTH_ENABLED:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version():
|
||||
|
||||
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}")
|
||||
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:
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14059,6 +14059,138 @@
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 8.5,
|
||||
"recommended_ram_gb": 11.0,
|
||||
"min_vram_gb": 7.5,
|
||||
"quantization": "Q4_K_M",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal; unsloth/gemma-4-12B-it-GGUF Dynamic variants reduce VRAM from ~7.5 GB to ~5.5 GB",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "unsloth/gemma-4-12B-it-GGUF",
|
||||
"provider": "unsloth"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-int4",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 8.0,
|
||||
"recommended_ram_gb": 9.5,
|
||||
"min_vram_gb": 6.5,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal (QAT quantization-aware training — higher quality than post-train INT4; vLLM native; no GGUF)",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-int8",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 15.0,
|
||||
"recommended_ram_gb": 20.0,
|
||||
"min_vram_gb": 13.5,
|
||||
"quantization": "QAT-INT8",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal (QAT INT8 — highest quality, 2x VRAM of QAT-INT4; vLLM native; no GGUF)",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 8.5,
|
||||
"recommended_ram_gb": 11.0,
|
||||
"min_vram_gb": 7.5,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 262144,
|
||||
"use_case": "General purpose, multimodal (vision + audio); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp/Ollama with CPU offload",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||
"provider": "Google",
|
||||
"file": "gemma-4-12b-it-qat-q4_0.gguf"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"vision",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||
"provider": "Google",
|
||||
"parameter_count": "25.2B",
|
||||
"parameters_raw": 25200000000,
|
||||
"min_ram_gb": 14.4,
|
||||
"recommended_ram_gb": 18.0,
|
||||
"min_vram_gb": 14.4,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 262144,
|
||||
"use_case": "High-throughput, multimodal MoE (3.8B active); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp with CPU offload",
|
||||
"is_moe": true,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": 3800000000,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||
"provider": "Google"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-31B-it",
|
||||
"provider": "Google",
|
||||
@@ -19144,4 +19276,4 @@
|
||||
],
|
||||
"_discovered": true
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -9,7 +9,7 @@ from services.hwfit.models import (
|
||||
GPU_BANDWIDTH = {
|
||||
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
|
||||
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
|
||||
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360,
|
||||
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360, "3050 ti": 192, "3050": 224,
|
||||
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
|
||||
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
|
||||
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
|
||||
|
||||
@@ -12,6 +12,7 @@ QUANT_BPP = {
|
||||
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
|
||||
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
||||
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
||||
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
|
||||
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
||||
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
|
||||
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
||||
@@ -30,6 +31,7 @@ QUANT_SPEED_MULT = {
|
||||
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
|
||||
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
||||
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
||||
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
|
||||
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
|
||||
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
|
||||
"FP8-Mixed": 0.85,
|
||||
@@ -47,6 +49,10 @@ QUANT_QUALITY_PENALTY = {
|
||||
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
|
||||
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
|
||||
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
|
||||
# Quantization-aware training recovers most of the int4 quality loss, so a
|
||||
# QAT-INT4 build lands far closer to bf16 than a post-training Q4/INT4
|
||||
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
|
||||
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
|
||||
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
|
||||
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
|
||||
# so the realized quality is much closer to FP8 than to pure FP4 —
|
||||
@@ -63,6 +69,7 @@ QUANT_BYTES_PER_PARAM = {
|
||||
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
|
||||
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
||||
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
||||
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
|
||||
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
||||
"FP4-MoE-Mixed": 0.55,
|
||||
"FP8-Mixed": 1.0,
|
||||
@@ -74,6 +81,7 @@ PREQUANTIZED_PREFIXES = (
|
||||
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
||||
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
||||
"FP4-MoE-Mixed", "FP8-Mixed",
|
||||
"QAT-",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -25,6 +25,11 @@ from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocument
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
from .bg_job_tools import ManageBgJobsTool
|
||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||
from .admin_tools import (
|
||||
ADMIN_TOOL_HANDLERS,
|
||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||
do_manage_tokens, do_manage_settings,
|
||||
)
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": BashTool().execute,
|
||||
@@ -52,6 +57,8 @@ TOOL_HANDLERS = {
|
||||
"send_to_session": SendToSessionTool().execute,
|
||||
"manage_session": ManageSessionTool().execute,
|
||||
}
|
||||
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants (re-exported for backward compatibility — single source of truth
|
||||
@@ -138,10 +145,5 @@ from src.tool_implementations import ( # noqa: E402, F401
|
||||
do_search_chats,
|
||||
do_manage_skills,
|
||||
do_manage_tasks,
|
||||
do_manage_endpoints,
|
||||
do_manage_mcp,
|
||||
do_manage_webhooks,
|
||||
do_manage_tokens,
|
||||
do_manage_settings,
|
||||
do_api_call,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
"""Config/integration admin agent tools (TOOL_HANDLERS).
|
||||
|
||||
Moved verbatim from tool_implementations.py as part of the tool-registry
|
||||
migration (#3629, the `admin_tools.py` bullet): manage_endpoints / manage_mcp /
|
||||
manage_webhooks / manage_tokens / manage_settings, plus manage_mcp's
|
||||
command-allowlist guard. Each impl keeps its `do_*(content, owner)` shape;
|
||||
ADMIN_TOOL_HANDLERS wraps them into registry `execute(content, ctx)` adapters
|
||||
via one factory.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional, Dict
|
||||
|
||||
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage model endpoints: list, add, delete, enable, disable."""
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if action == "list":
|
||||
eps = db.query(ModelEndpoint).all()
|
||||
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
|
||||
"is_enabled": e.is_enabled} for e in eps]
|
||||
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
|
||||
|
||||
elif action == "add":
|
||||
import uuid as _uuid
|
||||
name = args.get("name", "")
|
||||
base_url = args.get("base_url", "")
|
||||
api_key = args.get("api_key", "")
|
||||
if not base_url:
|
||||
return {"error": "base_url is required", "exit_code": 1}
|
||||
eid = str(_uuid.uuid4())[:8]
|
||||
from datetime import datetime
|
||||
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
||||
api_key=api_key, is_enabled=True,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
eid = args.get("endpoint_id", "")
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||
if not ep:
|
||||
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||
name = ep.name
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
|
||||
|
||||
elif action in ("enable", "disable"):
|
||||
eid = args.get("endpoint_id", "")
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||
if not ep:
|
||||
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||
ep.is_enabled = (action == "enable")
|
||||
db.commit()
|
||||
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_endpoints error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
|
||||
# opposite policy: that gate guards an admin-only serve command and allows
|
||||
# interpreters (python3/etc) because model-serving needs them, whereas this is
|
||||
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
|
||||
# runners are denied here.
|
||||
#
|
||||
# Commands that can execute arbitrary code regardless of their arguments. These
|
||||
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
|
||||
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
|
||||
# interpreter or package runner must be registered via the trusted admin route.
|
||||
_MCP_DENIED_COMMANDS = frozenset({
|
||||
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
|
||||
"cmd", "command.com", "powershell", "pwsh",
|
||||
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
|
||||
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
|
||||
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
|
||||
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
|
||||
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
|
||||
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
|
||||
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
|
||||
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
|
||||
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
|
||||
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
|
||||
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
|
||||
})
|
||||
|
||||
# Argv flags that make even an allowlisted binary execute inline code. Matched
|
||||
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
|
||||
# exact-token form.
|
||||
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
|
||||
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
|
||||
|
||||
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
|
||||
|
||||
# Shell metacharacters refused in command/args. Args are passed as an argv list
|
||||
# (no shell), but refusing these keeps the surface narrow and obvious.
|
||||
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
|
||||
|
||||
# Env vars that let a child process load attacker-supplied code before main().
|
||||
_MCP_DANGEROUS_ENV = frozenset({
|
||||
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
|
||||
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
|
||||
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
|
||||
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
|
||||
})
|
||||
|
||||
|
||||
def _mcp_allowed_commands() -> set:
|
||||
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
|
||||
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
|
||||
to opt specific trusted binaries in. Denied commands are rejected even if
|
||||
listed here."""
|
||||
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
|
||||
return {c.strip().lower() for c in raw.split(",") if c.strip()}
|
||||
|
||||
|
||||
def _validate_mcp_command(command, args, env) -> Optional[str]:
|
||||
"""Validate a model-supplied stdio MCP registration. Returns an error string
|
||||
if it must be rejected, else None.
|
||||
|
||||
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
|
||||
command/args/env straight to a subprocess spawn (issue #438): a payload
|
||||
smuggled into a skill description, memory entry, fetched page, or email body
|
||||
could register a stdio server running arbitrary code as the app UID.
|
||||
"""
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
return "command must be a non-empty string"
|
||||
command = command.strip()
|
||||
if "/" in command or "\\" in command:
|
||||
return "command must be a bare executable name, not a path"
|
||||
if any(ch in _MCP_SHELL_METACHARS for ch in command):
|
||||
return "command contains shell metacharacters"
|
||||
base = command.lower()
|
||||
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
|
||||
base = base.rsplit(".", 1)[0]
|
||||
# Canonicalize a trailing version suffix so versioned aliases collapse to the
|
||||
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
|
||||
# raw basename and the canonical form are denied, so an operator cannot
|
||||
# accidentally allowlist a runtime alias back into the path.
|
||||
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
|
||||
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
|
||||
return (
|
||||
f"command '{command}' is not allowed on the agent MCP path: "
|
||||
"interpreters, runtimes, package runners, and shells can execute "
|
||||
"arbitrary code. Register such a server via the admin route instead."
|
||||
)
|
||||
if base not in _mcp_allowed_commands():
|
||||
return (
|
||||
f"command '{command}' is not in the MCP allowlist. Add it to "
|
||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
|
||||
"server via the admin route."
|
||||
)
|
||||
|
||||
if args is not None:
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except Exception:
|
||||
return "args must be a JSON list"
|
||||
if not isinstance(args, list):
|
||||
return "args must be a list"
|
||||
for a in args:
|
||||
if not isinstance(a, str):
|
||||
return "args must all be strings"
|
||||
s = a.strip()
|
||||
low = s.lower()
|
||||
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
|
||||
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
|
||||
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
|
||||
return f"arg '{a}' is a remote URL and is not allowed"
|
||||
if any(ch in _MCP_SHELL_METACHARS for ch in a):
|
||||
return f"arg '{a}' contains shell metacharacters"
|
||||
|
||||
if env:
|
||||
if isinstance(env, str):
|
||||
try:
|
||||
env = json.loads(env)
|
||||
except Exception:
|
||||
return "env must be a JSON object"
|
||||
if not isinstance(env, dict):
|
||||
return "env must be an object"
|
||||
for k in env:
|
||||
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
|
||||
return f"env var '{k}' can inject code into the child process and is not allowed"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
|
||||
if action == "list":
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
|
||||
from core.database import SessionLocal, McpServer
|
||||
db = SessionLocal()
|
||||
try:
|
||||
servers = db.query(McpServer).all()
|
||||
items = []
|
||||
for s in servers:
|
||||
st = mcp.get_server_status(s.id)
|
||||
status = st.get("status", "disconnected")
|
||||
tool_count = st.get("tool_count", 0)
|
||||
items.append({"id": s.id, "name": s.name, "transport": s.transport,
|
||||
"is_enabled": s.is_enabled, "status": status,
|
||||
"tool_count": tool_count})
|
||||
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
elif action == "add":
|
||||
from core.database import SessionLocal, McpServer
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
name = args.get("name", "")
|
||||
command = args.get("command", "")
|
||||
cmd_args = args.get("args", [])
|
||||
env = args.get("env", {})
|
||||
if not name or not command:
|
||||
return {"error": "name and command are required", "exit_code": 1}
|
||||
# Validate BEFORE any DB write or spawn: a rejected registration must
|
||||
# leave no enabled row (which would otherwise auto-reconnect on restart)
|
||||
# and must not attempt a connection.
|
||||
_mcp_err = _validate_mcp_command(command, cmd_args, env)
|
||||
if _mcp_err:
|
||||
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
|
||||
sid = str(_uuid.uuid4())[:8]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
|
||||
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
|
||||
env=json.dumps(env) if isinstance(env, dict) else env,
|
||||
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(srv)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
# Try to connect
|
||||
mcp = get_mcp_manager()
|
||||
tool_count = 0
|
||||
if mcp:
|
||||
try:
|
||||
await mcp.connect_server(
|
||||
sid, name, "stdio", command=command,
|
||||
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
|
||||
env=env if isinstance(env, dict) else json.loads(env),
|
||||
)
|
||||
st = mcp.get_server_status(sid)
|
||||
tool_count = st.get("tool_count", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP connect failed for {name}: {e}")
|
||||
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
sid = args.get("server_id", "")
|
||||
from core.database import SessionLocal, McpServer
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||
if not srv:
|
||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||
name = srv.name
|
||||
mcp = get_mcp_manager()
|
||||
if mcp:
|
||||
try:
|
||||
await mcp.disconnect_server(sid)
|
||||
except Exception:
|
||||
pass
|
||||
db.delete(srv)
|
||||
db.commit()
|
||||
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
elif action == "reconnect":
|
||||
sid = args.get("server_id", "")
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return {"error": "MCP manager not available", "exit_code": 1}
|
||||
try:
|
||||
await mcp.disconnect_server(sid)
|
||||
from core.database import SessionLocal, McpServer
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
|
||||
if srv:
|
||||
_args = json.loads(srv.args) if srv.args else []
|
||||
_env = json.loads(srv.env) if srv.env else {}
|
||||
await mcp.connect_server(
|
||||
server_id=sid,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=_args,
|
||||
env=_env,
|
||||
url=srv.url,
|
||||
)
|
||||
st = mcp.get_server_status(sid)
|
||||
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
|
||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
elif action in ("enable", "disable"):
|
||||
sid = args.get("server_id", "")
|
||||
from core.database import SessionLocal, McpServer
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||
if not srv:
|
||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||
srv.is_enabled = (action == "enable")
|
||||
db.commit()
|
||||
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
elif action == "list_tools":
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
|
||||
tools = mcp.get_all_tools()
|
||||
items = [{"name": t["name"], "server": t["server_name"],
|
||||
"description": t.get("description", "")[:100]} for t in tools]
|
||||
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage webhooks: list, add, delete, enable, disable, test."""
|
||||
from core.database import SessionLocal
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from core.database import Webhook
|
||||
if action == "list":
|
||||
hooks = db.query(Webhook).all()
|
||||
items = [{"id": h.id, "name": h.name, "url": h.url,
|
||||
"events": h.events, "is_active": h.is_active} for h in hooks]
|
||||
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
|
||||
|
||||
elif action == "add":
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
from src.webhook_manager import validate_events, validate_webhook_url
|
||||
name = args.get("name", "")
|
||||
url = args.get("url", "")
|
||||
events = args.get("events", "chat.completed")
|
||||
if not url:
|
||||
return {"error": "url is required", "exit_code": 1}
|
||||
try:
|
||||
url = validate_webhook_url(url)
|
||||
events = validate_events(events)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
wid = str(_uuid.uuid4())[:8]
|
||||
hook = Webhook(id=wid, name=name or url, url=url,
|
||||
events=events, is_active=True,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(hook)
|
||||
db.commit()
|
||||
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
wid = args.get("webhook_id", "")
|
||||
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||
if not hook:
|
||||
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||
name = hook.name
|
||||
db.delete(hook)
|
||||
db.commit()
|
||||
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
|
||||
|
||||
elif action in ("enable", "disable"):
|
||||
wid = args.get("webhook_id", "")
|
||||
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||
if not hook:
|
||||
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||
hook.is_active = (action == "enable")
|
||||
db.commit()
|
||||
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_webhooks error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API token management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage API tokens: list, create, delete."""
|
||||
from core.database import SessionLocal, ApiToken
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if action == "list":
|
||||
tokens = db.query(ApiToken).all()
|
||||
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
|
||||
"is_active": t.is_active} for t in tokens]
|
||||
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
|
||||
|
||||
elif action == "create":
|
||||
import uuid as _uuid, secrets, bcrypt
|
||||
from datetime import datetime
|
||||
name = args.get("name", "API Token")
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
tid = str(_uuid.uuid4())[:8]
|
||||
t = ApiToken(id=tid, name=name, token_hash=token_hash,
|
||||
token_prefix=raw_token[:8], is_active=True,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(t)
|
||||
db.commit()
|
||||
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
tid = args.get("token_id", "")
|
||||
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
|
||||
if not t:
|
||||
return {"error": f"Token {tid} not found", "exit_code": 1}
|
||||
name = t.name
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"response": f"Deleted token '{name}'", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_tokens error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings/preferences management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage user settings and preferences."""
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
|
||||
from core.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# set/get/list/delete operate on the REAL app settings (the same store
|
||||
# the Settings panel writes), so changing a model / voice / search
|
||||
# engine / reminder channel from chat actually takes effect.
|
||||
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
||||
|
||||
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||
# so API keys never flow through chat. User sets these in the panel.
|
||||
_SECRET_KEYS = {
|
||||
"brave_api_key", "google_pse_key", "google_pse_cx",
|
||||
"tavily_api_key", "serper_api_key", "app_public_url",
|
||||
}
|
||||
def _is_secret(k):
|
||||
# `token` must be a suffix, not a substring: otherwise the int
|
||||
# setting `agent_input_token_budget` (which even has a "token budget"
|
||||
# alias to set it from chat) is wrongly classified as a credential.
|
||||
return (
|
||||
k in _SECRET_KEYS
|
||||
or k.endswith("token")
|
||||
or any(t in k for t in ("api_key", "_key", "secret", "password"))
|
||||
)
|
||||
|
||||
# Friendly aliases → real keys, so natural phrasing resolves.
|
||||
_ALIASES_SET = {
|
||||
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
|
||||
"text to speech": "tts_enabled", "tts provider": "tts_provider",
|
||||
"speech speed": "tts_speed", "voice speed": "tts_speed",
|
||||
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
|
||||
"search engine": "search_provider", "search provider": "search_provider",
|
||||
"search results": "search_result_count", "result count": "search_result_count",
|
||||
"default model": "default_model", "chat model": "default_model",
|
||||
"default endpoint": "default_endpoint_id",
|
||||
"task model": "task_model", "background model": "task_model",
|
||||
"teacher model": "teacher_model", "teacher": "teacher_enabled",
|
||||
"utility model": "utility_model", "research model": "research_model",
|
||||
"research max tokens": "research_max_tokens",
|
||||
"vision model": "vision_model", "vision": "vision_enabled",
|
||||
"image model": "image_model", "image quality": "image_quality",
|
||||
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
|
||||
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
|
||||
"ntfy topic": "reminder_ntfy_topic",
|
||||
"webhook integration": "reminder_webhook_integration_id",
|
||||
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
|
||||
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
|
||||
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
|
||||
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
|
||||
"hard max": "agent_input_token_hard_max",
|
||||
"token budget cap": "agent_input_token_hard_max",
|
||||
"input budget cap": "agent_input_token_hard_max",
|
||||
}
|
||||
def _resolve(k):
|
||||
k2 = (k or "").strip().lower()
|
||||
if k2 in DEFAULT_SETTINGS:
|
||||
return k2
|
||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||
|
||||
_ENUMS = {
|
||||
"image_quality": ["low", "medium", "high"],
|
||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||
}
|
||||
def _coerce(value, default):
|
||||
if isinstance(default, bool):
|
||||
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
|
||||
if isinstance(default, int):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
def _model_slug(value: str) -> str:
|
||||
import re as _re
|
||||
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
|
||||
|
||||
def _endpoint_model_from_cache(model_query: str):
|
||||
"""Resolve friendly model text to an enabled endpoint + real model id.
|
||||
|
||||
The Settings UI stores both `<prefix>_endpoint_id` and
|
||||
`<prefix>_model`; writing only the model leaves the runtime on the
|
||||
old endpoint. Prefer cached model lists so this stays fast/offline.
|
||||
"""
|
||||
import json as _json
|
||||
import re as _re
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
wanted = (model_query or "").strip()
|
||||
wanted_slug = _model_slug(wanted)
|
||||
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
|
||||
wanted_tokens = [t for t in wanted_tokens if t]
|
||||
if not wanted_slug:
|
||||
return None
|
||||
best = None
|
||||
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
|
||||
raw_models = []
|
||||
try:
|
||||
raw_models = _json.loads(ep.cached_models or "[]") or []
|
||||
except Exception:
|
||||
raw_models = []
|
||||
# If cache is empty, still allow matching against endpoint name
|
||||
# for callers using model@endpoint elsewhere later.
|
||||
for mid in raw_models:
|
||||
mid = str(mid)
|
||||
mid_slug = _model_slug(mid)
|
||||
if not mid_slug:
|
||||
continue
|
||||
exact = mid.lower() == wanted.lower()
|
||||
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
|
||||
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
|
||||
if exact or compact_match or token_match:
|
||||
score = 3 if exact else (2 if compact_match else 1)
|
||||
if not best or score > best[0]:
|
||||
best = (score, ep.id, mid)
|
||||
if best:
|
||||
return {"endpoint_id": best[1], "model": best[2]}
|
||||
return None
|
||||
|
||||
def _mask(k, v):
|
||||
return "••••• (set in panel)" if _is_secret(k) and v else v
|
||||
|
||||
if action == "list":
|
||||
s = load_settings()
|
||||
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
||||
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||
|
||||
elif action == "get":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if not key:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||
|
||||
elif action == "set":
|
||||
raw = args.get("key", "")
|
||||
value = args.get("value")
|
||||
if not raw:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
key = _resolve(raw)
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
||||
# have no safe scalar coercion; _coerce would pass a bare string
|
||||
# straight through and clobber the structure. Refuse them here; they're
|
||||
# edited in their dedicated panels. (reset/delete still restore the
|
||||
# default structure, which is safe.)
|
||||
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
|
||||
return {"response": f"'{key}' is a structured setting. Edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
|
||||
try:
|
||||
value = _coerce(value, DEFAULT_SETTINGS[key])
|
||||
except (ValueError, TypeError):
|
||||
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
|
||||
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
|
||||
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
|
||||
s = load_settings()
|
||||
s[key] = value
|
||||
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
|
||||
resolved = _endpoint_model_from_cache(str(value))
|
||||
if resolved:
|
||||
prefix = key[:-6]
|
||||
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
|
||||
s[key] = resolved["model"]
|
||||
value = resolved["model"]
|
||||
save_settings(s)
|
||||
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
|
||||
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
|
||||
return {"response": f"Set {key} = {value}.", "exit_code": 0}
|
||||
|
||||
elif action == "delete" or action == "reset":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||
s = load_settings()
|
||||
s[key] = DEFAULT_SETTINGS[key]
|
||||
save_settings(s)
|
||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||
|
||||
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||
# (the global list read on every chat request) rather than
|
||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||
# "search" -> "web_search", "browser" -> "builtin_browser",
|
||||
# "documents" -> the document tool set, "memory" ->
|
||||
# manage_memory, etc.
|
||||
from src.settings import get_setting, save_settings, load_settings
|
||||
_ALIASES = {
|
||||
"shell": ["bash"],
|
||||
"terminal": ["bash"],
|
||||
"search": ["web_search", "web_fetch"],
|
||||
"web": ["web_search", "web_fetch"],
|
||||
"browser": ["builtin_browser"],
|
||||
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||
"memory": ["manage_memory"],
|
||||
"skills": ["manage_skills"],
|
||||
"images": ["generate_image"],
|
||||
"image": ["generate_image"],
|
||||
"tasks": ["manage_tasks"],
|
||||
"notes": ["manage_notes"],
|
||||
"calendar": ["manage_calendar"],
|
||||
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
|
||||
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog)
|
||||
}
|
||||
|
||||
if action == "list_tools":
|
||||
current = get_setting("disabled_tools", []) or []
|
||||
return {
|
||||
"response": (
|
||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||
"memory, skills, images, tasks, notes, calendar, email."
|
||||
),
|
||||
"disabled": list(current),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||
if not tool_name:
|
||||
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
|
||||
targets = _ALIASES.get(tool_name, [tool_name])
|
||||
|
||||
settings = load_settings()
|
||||
current = list(settings.get("disabled_tools") or [])
|
||||
before = set(current)
|
||||
if action == "disable_tool":
|
||||
for t in targets:
|
||||
if t not in current:
|
||||
current.append(t)
|
||||
else: # enable_tool
|
||||
current = [t for t in current if t not in targets]
|
||||
after = set(current)
|
||||
settings["disabled_tools"] = current
|
||||
save_settings(settings)
|
||||
|
||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
||||
changed = sorted(after.symmetric_difference(before))
|
||||
return {
|
||||
"response": (
|
||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
||||
),
|
||||
"changed": changed,
|
||||
"disabled": list(current),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_settings error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API call tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
# ── registry adapters ────────────────────────────────────────────────────────
|
||||
def _owner_adapter(fn):
|
||||
"""Wrap a do_*(content, owner) impl as a registry execute(content, ctx)."""
|
||||
async def _execute(content: str, ctx: dict) -> dict:
|
||||
return await fn(content, ctx.get("owner"))
|
||||
return _execute
|
||||
|
||||
|
||||
ADMIN_TOOL_HANDLERS = {
|
||||
"manage_endpoints": _owner_adapter(do_manage_endpoints),
|
||||
"manage_mcp": _owner_adapter(do_manage_mcp),
|
||||
"manage_webhooks": _owner_adapter(do_manage_webhooks),
|
||||
"manage_tokens": _owner_adapter(do_manage_tokens),
|
||||
"manage_settings": _owner_adapter(do_manage_settings),
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
from src.constants import MAX_READ_CHARS
|
||||
from src.tool_utils import _parse_tool_args
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,38 +154,6 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||
body = new
|
||||
return header.rstrip() + "\n---\n" + body
|
||||
|
||||
def _parse_tool_args(content):
|
||||
"""Parse a tool-call argument blob.
|
||||
|
||||
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
||||
common `{"body": {...}}` envelope that smaller models emit when they
|
||||
read tool descriptions like "Body is JSON: {...}" literally — they
|
||||
pass `body` as a field name rather than treating it as a noun.
|
||||
|
||||
Returns a dict on success, raises ValueError on bad JSON.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
args = json.loads(content) if content.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
raise ValueError(str(e))
|
||||
elif isinstance(content, dict):
|
||||
args = content
|
||||
else:
|
||||
args = {}
|
||||
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
|
||||
# and points at a dict. We don't want to clobber a legitimate `body`
|
||||
# field on tools where it's a real arg (e.g. send_email body text).
|
||||
if (
|
||||
isinstance(args, dict)
|
||||
and len(args) == 1
|
||||
and "body" in args
|
||||
and isinstance(args["body"], dict)
|
||||
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
||||
):
|
||||
args = args["body"]
|
||||
return args
|
||||
|
||||
def parse_edit_blocks(content: str) -> list:
|
||||
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
|
||||
edits = []
|
||||
|
||||
+17
-2
@@ -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"""
|
||||
|
||||
+30
-1
@@ -1,6 +1,13 @@
|
||||
# src/app_helpers.py
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def read_if_exists(path: str) -> str:
|
||||
"""Read file if it exists, return empty string otherwise."""
|
||||
@@ -20,6 +27,28 @@ def abs_join(base_dir: str, rel: str) -> str:
|
||||
"""Join paths and return absolute path."""
|
||||
return os.path.abspath(os.path.join(base_dir, rel))
|
||||
|
||||
def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||
"""Read an app-bundled HTML page and inject the CSP nonce into inline <script> tags.
|
||||
|
||||
Callers pass fixed, server-owned template paths (index/login/backgrounds),
|
||||
never a client-supplied path. So any read failure here — a missing file
|
||||
(broken deployment) or a permission/IO error — is a server fault, not a
|
||||
client "not found": map all of them to a logged 500 so a missing core
|
||||
template surfaces in 5xx alerting instead of hiding behind a 404. If a
|
||||
future caller serves a client-influenced path where 404 is correct, branch
|
||||
that at the call site rather than defaulting this shared helper to 404.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
except OSError:
|
||||
logger.exception("Failed to read page %s", file_path)
|
||||
raise HTTPException(500, "Internal server error")
|
||||
nonce = getattr(request.state, "csp_nonce", "")
|
||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
def inside_base_dir(base_dir: str, path: str) -> bool:
|
||||
"""Check if path is inside base directory."""
|
||||
if not isinstance(base_dir, str) or not isinstance(path, str):
|
||||
|
||||
+8
-1
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-18
@@ -563,9 +563,7 @@ async def _execute_tool_block_impl(
|
||||
"""
|
||||
from src.tool_implementations import (
|
||||
do_search_chats, do_manage_tasks,
|
||||
do_manage_skills, do_api_call, do_manage_endpoints,
|
||||
do_manage_mcp, do_manage_webhooks, do_manage_tokens,
|
||||
do_manage_settings, do_manage_notes,
|
||||
do_manage_skills, do_api_call, do_manage_notes,
|
||||
do_manage_calendar,
|
||||
do_download_model, do_serve_model, do_list_served_models, do_stop_served_model,
|
||||
do_tail_serve_output,
|
||||
@@ -808,21 +806,11 @@ async def _execute_tool_block_impl(
|
||||
first_line = content.split("\n")[0].strip()[:60]
|
||||
desc = f"api_call: {first_line}"
|
||||
result = await do_api_call(content)
|
||||
elif tool == "manage_endpoints":
|
||||
desc = "manage_endpoints"
|
||||
result = await do_manage_endpoints(content, owner=owner)
|
||||
elif tool == "manage_mcp":
|
||||
desc = "manage_mcp"
|
||||
result = await do_manage_mcp(content, owner=owner)
|
||||
elif tool == "manage_webhooks":
|
||||
desc = "manage_webhooks"
|
||||
result = await do_manage_webhooks(content, owner=owner)
|
||||
elif tool == "manage_tokens":
|
||||
desc = "manage_tokens"
|
||||
result = await do_manage_tokens(content, owner=owner)
|
||||
elif tool == "manage_settings":
|
||||
desc = "manage_settings"
|
||||
result = await do_manage_settings(content, owner=owner)
|
||||
elif tool in ("manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "manage_settings"):
|
||||
# Registry-dispatched (agent_tools.admin_tools); owner threaded for ownership/admin checks.
|
||||
desc = tool
|
||||
result = await _direct_fallback(tool, content, owner=owner) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool == "manage_notes":
|
||||
desc = "manage_notes"
|
||||
result = await do_manage_notes(content, owner=owner)
|
||||
|
||||
+2
-785
@@ -14,7 +14,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
|
||||
from src.tool_utils import get_mcp_manager
|
||||
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
||||
from core.constants import internal_api_base
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
|
||||
@@ -68,38 +68,6 @@ def clear_active_email() -> None:
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_tool_args(content):
|
||||
"""Parse a tool-call argument blob.
|
||||
|
||||
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
||||
common `{"body": {...}}` envelope that smaller models emit when they
|
||||
read tool descriptions like "Body is JSON: {...}" literally — they
|
||||
pass `body` as a field name rather than treating it as a noun.
|
||||
|
||||
Returns a dict on success, raises ValueError on bad JSON.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
args = json.loads(content) if content.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
raise ValueError(str(e))
|
||||
elif isinstance(content, dict):
|
||||
args = content
|
||||
else:
|
||||
args = {}
|
||||
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
|
||||
# and points at a dict. We don't want to clobber a legitimate `body`
|
||||
# field on tools where it's a real arg (e.g. send_email body text).
|
||||
if (
|
||||
isinstance(args, dict)
|
||||
and len(args) == 1
|
||||
and "body" in args
|
||||
and isinstance(args["body"], dict)
|
||||
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
||||
):
|
||||
args = args["body"]
|
||||
return args
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search chats
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -588,757 +556,6 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage model endpoints: list, add, delete, enable, disable."""
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if action == "list":
|
||||
eps = db.query(ModelEndpoint).all()
|
||||
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
|
||||
"is_enabled": e.is_enabled} for e in eps]
|
||||
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
|
||||
|
||||
elif action == "add":
|
||||
import uuid as _uuid
|
||||
name = args.get("name", "")
|
||||
base_url = args.get("base_url", "")
|
||||
api_key = args.get("api_key", "")
|
||||
if not base_url:
|
||||
return {"error": "base_url is required", "exit_code": 1}
|
||||
eid = str(_uuid.uuid4())[:8]
|
||||
from datetime import datetime
|
||||
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
||||
api_key=api_key, is_enabled=True,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
eid = args.get("endpoint_id", "")
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||
if not ep:
|
||||
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||
name = ep.name
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
|
||||
|
||||
elif action in ("enable", "disable"):
|
||||
eid = args.get("endpoint_id", "")
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||
if not ep:
|
||||
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||
ep.is_enabled = (action == "enable")
|
||||
db.commit()
|
||||
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_endpoints error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
|
||||
# opposite policy: that gate guards an admin-only serve command and allows
|
||||
# interpreters (python3/etc) because model-serving needs them, whereas this is
|
||||
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
|
||||
# runners are denied here.
|
||||
#
|
||||
# Commands that can execute arbitrary code regardless of their arguments. These
|
||||
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
|
||||
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
|
||||
# interpreter or package runner must be registered via the trusted admin route.
|
||||
_MCP_DENIED_COMMANDS = frozenset({
|
||||
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
|
||||
"cmd", "command.com", "powershell", "pwsh",
|
||||
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
|
||||
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
|
||||
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
|
||||
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
|
||||
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
|
||||
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
|
||||
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
|
||||
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
|
||||
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
|
||||
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
|
||||
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
|
||||
})
|
||||
|
||||
# Argv flags that make even an allowlisted binary execute inline code. Matched
|
||||
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
|
||||
# exact-token form.
|
||||
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
|
||||
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
|
||||
|
||||
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
|
||||
|
||||
# Shell metacharacters refused in command/args. Args are passed as an argv list
|
||||
# (no shell), but refusing these keeps the surface narrow and obvious.
|
||||
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
|
||||
|
||||
# Env vars that let a child process load attacker-supplied code before main().
|
||||
_MCP_DANGEROUS_ENV = frozenset({
|
||||
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
|
||||
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
|
||||
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
|
||||
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
|
||||
})
|
||||
|
||||
|
||||
def _mcp_allowed_commands() -> set:
|
||||
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
|
||||
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
|
||||
to opt specific trusted binaries in. Denied commands are rejected even if
|
||||
listed here."""
|
||||
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
|
||||
return {c.strip().lower() for c in raw.split(",") if c.strip()}
|
||||
|
||||
|
||||
def _validate_mcp_command(command, args, env) -> Optional[str]:
|
||||
"""Validate a model-supplied stdio MCP registration. Returns an error string
|
||||
if it must be rejected, else None.
|
||||
|
||||
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
|
||||
command/args/env straight to a subprocess spawn (issue #438): a payload
|
||||
smuggled into a skill description, memory entry, fetched page, or email body
|
||||
could register a stdio server running arbitrary code as the app UID.
|
||||
"""
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
return "command must be a non-empty string"
|
||||
command = command.strip()
|
||||
if "/" in command or "\\" in command:
|
||||
return "command must be a bare executable name, not a path"
|
||||
if any(ch in _MCP_SHELL_METACHARS for ch in command):
|
||||
return "command contains shell metacharacters"
|
||||
base = command.lower()
|
||||
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
|
||||
base = base.rsplit(".", 1)[0]
|
||||
# Canonicalize a trailing version suffix so versioned aliases collapse to the
|
||||
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
|
||||
# raw basename and the canonical form are denied, so an operator cannot
|
||||
# accidentally allowlist a runtime alias back into the path.
|
||||
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
|
||||
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
|
||||
return (
|
||||
f"command '{command}' is not allowed on the agent MCP path: "
|
||||
"interpreters, runtimes, package runners, and shells can execute "
|
||||
"arbitrary code. Register such a server via the admin route instead."
|
||||
)
|
||||
if base not in _mcp_allowed_commands():
|
||||
return (
|
||||
f"command '{command}' is not in the MCP allowlist. Add it to "
|
||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
|
||||
"server via the admin route."
|
||||
)
|
||||
|
||||
if args is not None:
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except Exception:
|
||||
return "args must be a JSON list"
|
||||
if not isinstance(args, list):
|
||||
return "args must be a list"
|
||||
for a in args:
|
||||
if not isinstance(a, str):
|
||||
return "args must all be strings"
|
||||
s = a.strip()
|
||||
low = s.lower()
|
||||
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
|
||||
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
|
||||
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
|
||||
return f"arg '{a}' is a remote URL and is not allowed"
|
||||
if any(ch in _MCP_SHELL_METACHARS for ch in a):
|
||||
return f"arg '{a}' contains shell metacharacters"
|
||||
|
||||
if env:
|
||||
if isinstance(env, str):
|
||||
try:
|
||||
env = json.loads(env)
|
||||
except Exception:
|
||||
return "env must be a JSON object"
|
||||
if not isinstance(env, dict):
|
||||
return "env must be an object"
|
||||
for k in env:
|
||||
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
|
||||
return f"env var '{k}' can inject code into the child process and is not allowed"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
|
||||
if action == "list":
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
|
||||
from core.database import SessionLocal, McpServer
|
||||
db = SessionLocal()
|
||||
try:
|
||||
servers = db.query(McpServer).all()
|
||||
items = []
|
||||
for s in servers:
|
||||
st = mcp.get_server_status(s.id)
|
||||
status = st.get("status", "disconnected")
|
||||
tool_count = st.get("tool_count", 0)
|
||||
items.append({"id": s.id, "name": s.name, "transport": s.transport,
|
||||
"is_enabled": s.is_enabled, "status": status,
|
||||
"tool_count": tool_count})
|
||||
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
elif action == "add":
|
||||
from core.database import SessionLocal, McpServer
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
name = args.get("name", "")
|
||||
command = args.get("command", "")
|
||||
cmd_args = args.get("args", [])
|
||||
env = args.get("env", {})
|
||||
if not name or not command:
|
||||
return {"error": "name and command are required", "exit_code": 1}
|
||||
# Validate BEFORE any DB write or spawn: a rejected registration must
|
||||
# leave no enabled row (which would otherwise auto-reconnect on restart)
|
||||
# and must not attempt a connection.
|
||||
_mcp_err = _validate_mcp_command(command, cmd_args, env)
|
||||
if _mcp_err:
|
||||
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
|
||||
sid = str(_uuid.uuid4())[:8]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
|
||||
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
|
||||
env=json.dumps(env) if isinstance(env, dict) else env,
|
||||
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(srv)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
# Try to connect
|
||||
mcp = get_mcp_manager()
|
||||
tool_count = 0
|
||||
if mcp:
|
||||
try:
|
||||
await mcp.connect_server(
|
||||
sid, name, "stdio", command=command,
|
||||
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
|
||||
env=env if isinstance(env, dict) else json.loads(env),
|
||||
)
|
||||
st = mcp.get_server_status(sid)
|
||||
tool_count = st.get("tool_count", 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP connect failed for {name}: {e}")
|
||||
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
sid = args.get("server_id", "")
|
||||
from core.database import SessionLocal, McpServer
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||
if not srv:
|
||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||
name = srv.name
|
||||
mcp = get_mcp_manager()
|
||||
if mcp:
|
||||
try:
|
||||
await mcp.disconnect_server(sid)
|
||||
except Exception:
|
||||
pass
|
||||
db.delete(srv)
|
||||
db.commit()
|
||||
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
elif action == "reconnect":
|
||||
sid = args.get("server_id", "")
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return {"error": "MCP manager not available", "exit_code": 1}
|
||||
try:
|
||||
await mcp.disconnect_server(sid)
|
||||
from core.database import SessionLocal, McpServer
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
|
||||
if srv:
|
||||
_args = json.loads(srv.args) if srv.args else []
|
||||
_env = json.loads(srv.env) if srv.env else {}
|
||||
await mcp.connect_server(
|
||||
server_id=sid,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=_args,
|
||||
env=_env,
|
||||
url=srv.url,
|
||||
)
|
||||
st = mcp.get_server_status(sid)
|
||||
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
|
||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
elif action in ("enable", "disable"):
|
||||
sid = args.get("server_id", "")
|
||||
from core.database import SessionLocal, McpServer
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||
if not srv:
|
||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||
srv.is_enabled = (action == "enable")
|
||||
db.commit()
|
||||
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
elif action == "list_tools":
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
|
||||
tools = mcp.get_all_tools()
|
||||
items = [{"name": t["name"], "server": t["server_name"],
|
||||
"description": t.get("description", "")[:100]} for t in tools]
|
||||
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage webhooks: list, add, delete, enable, disable, test."""
|
||||
from core.database import SessionLocal
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from core.database import Webhook
|
||||
if action == "list":
|
||||
hooks = db.query(Webhook).all()
|
||||
items = [{"id": h.id, "name": h.name, "url": h.url,
|
||||
"events": h.events, "is_active": h.is_active} for h in hooks]
|
||||
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
|
||||
|
||||
elif action == "add":
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
from src.webhook_manager import validate_events, validate_webhook_url
|
||||
name = args.get("name", "")
|
||||
url = args.get("url", "")
|
||||
events = args.get("events", "chat.completed")
|
||||
if not url:
|
||||
return {"error": "url is required", "exit_code": 1}
|
||||
try:
|
||||
url = validate_webhook_url(url)
|
||||
events = validate_events(events)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
wid = str(_uuid.uuid4())[:8]
|
||||
hook = Webhook(id=wid, name=name or url, url=url,
|
||||
events=events, is_active=True,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(hook)
|
||||
db.commit()
|
||||
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
wid = args.get("webhook_id", "")
|
||||
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||
if not hook:
|
||||
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||
name = hook.name
|
||||
db.delete(hook)
|
||||
db.commit()
|
||||
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
|
||||
|
||||
elif action in ("enable", "disable"):
|
||||
wid = args.get("webhook_id", "")
|
||||
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||
if not hook:
|
||||
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||
hook.is_active = (action == "enable")
|
||||
db.commit()
|
||||
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_webhooks error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API token management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage API tokens: list, create, delete."""
|
||||
from core.database import SessionLocal, ApiToken
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if action == "list":
|
||||
tokens = db.query(ApiToken).all()
|
||||
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
|
||||
"is_active": t.is_active} for t in tokens]
|
||||
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
|
||||
|
||||
elif action == "create":
|
||||
import uuid as _uuid, secrets, bcrypt
|
||||
from datetime import datetime
|
||||
name = args.get("name", "API Token")
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
tid = str(_uuid.uuid4())[:8]
|
||||
t = ApiToken(id=tid, name=name, token_hash=token_hash,
|
||||
token_prefix=raw_token[:8], is_active=True,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||
db.add(t)
|
||||
db.commit()
|
||||
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
tid = args.get("token_id", "")
|
||||
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
|
||||
if not t:
|
||||
return {"error": f"Token {tid} not found", "exit_code": 1}
|
||||
name = t.name
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"response": f"Deleted token '{name}'", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_tokens error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings/preferences management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage user settings and preferences."""
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = args.get("action", "list")
|
||||
|
||||
from core.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# set/get/list/delete operate on the REAL app settings (the same store
|
||||
# the Settings panel writes), so changing a model / voice / search
|
||||
# engine / reminder channel from chat actually takes effect.
|
||||
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
||||
|
||||
# Secrets/credentials the agent must NOT write — kept read-only (masked)
|
||||
# so API keys never flow through chat. User sets these in the panel.
|
||||
_SECRET_KEYS = {
|
||||
"brave_api_key", "google_pse_key", "google_pse_cx",
|
||||
"tavily_api_key", "serper_api_key", "app_public_url",
|
||||
}
|
||||
def _is_secret(k):
|
||||
# `token` must be a suffix, not a substring: otherwise the int
|
||||
# setting `agent_input_token_budget` (which even has a "token budget"
|
||||
# alias to set it from chat) is wrongly classified as a credential.
|
||||
return (
|
||||
k in _SECRET_KEYS
|
||||
or k.endswith("token")
|
||||
or any(t in k for t in ("api_key", "_key", "secret", "password"))
|
||||
)
|
||||
|
||||
# Friendly aliases → real keys, so natural phrasing resolves.
|
||||
_ALIASES_SET = {
|
||||
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
|
||||
"text to speech": "tts_enabled", "tts provider": "tts_provider",
|
||||
"speech speed": "tts_speed", "voice speed": "tts_speed",
|
||||
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
|
||||
"search engine": "search_provider", "search provider": "search_provider",
|
||||
"search results": "search_result_count", "result count": "search_result_count",
|
||||
"default model": "default_model", "chat model": "default_model",
|
||||
"default endpoint": "default_endpoint_id",
|
||||
"task model": "task_model", "background model": "task_model",
|
||||
"teacher model": "teacher_model", "teacher": "teacher_enabled",
|
||||
"utility model": "utility_model", "research model": "research_model",
|
||||
"research max tokens": "research_max_tokens",
|
||||
"vision model": "vision_model", "vision": "vision_enabled",
|
||||
"image model": "image_model", "image quality": "image_quality",
|
||||
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
|
||||
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
|
||||
"ntfy topic": "reminder_ntfy_topic",
|
||||
"webhook integration": "reminder_webhook_integration_id",
|
||||
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
|
||||
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
|
||||
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
|
||||
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
|
||||
"hard max": "agent_input_token_hard_max",
|
||||
"token budget cap": "agent_input_token_hard_max",
|
||||
"input budget cap": "agent_input_token_hard_max",
|
||||
}
|
||||
def _resolve(k):
|
||||
k2 = (k or "").strip().lower()
|
||||
if k2 in DEFAULT_SETTINGS:
|
||||
return k2
|
||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||
|
||||
_ENUMS = {
|
||||
"image_quality": ["low", "medium", "high"],
|
||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||
}
|
||||
def _coerce(value, default):
|
||||
if isinstance(default, bool):
|
||||
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
|
||||
if isinstance(default, int):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
def _model_slug(value: str) -> str:
|
||||
import re as _re
|
||||
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
|
||||
|
||||
def _endpoint_model_from_cache(model_query: str):
|
||||
"""Resolve friendly model text to an enabled endpoint + real model id.
|
||||
|
||||
The Settings UI stores both `<prefix>_endpoint_id` and
|
||||
`<prefix>_model`; writing only the model leaves the runtime on the
|
||||
old endpoint. Prefer cached model lists so this stays fast/offline.
|
||||
"""
|
||||
import json as _json
|
||||
import re as _re
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
wanted = (model_query or "").strip()
|
||||
wanted_slug = _model_slug(wanted)
|
||||
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
|
||||
wanted_tokens = [t for t in wanted_tokens if t]
|
||||
if not wanted_slug:
|
||||
return None
|
||||
best = None
|
||||
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
|
||||
raw_models = []
|
||||
try:
|
||||
raw_models = _json.loads(ep.cached_models or "[]") or []
|
||||
except Exception:
|
||||
raw_models = []
|
||||
# If cache is empty, still allow matching against endpoint name
|
||||
# for callers using model@endpoint elsewhere later.
|
||||
for mid in raw_models:
|
||||
mid = str(mid)
|
||||
mid_slug = _model_slug(mid)
|
||||
if not mid_slug:
|
||||
continue
|
||||
exact = mid.lower() == wanted.lower()
|
||||
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
|
||||
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
|
||||
if exact or compact_match or token_match:
|
||||
score = 3 if exact else (2 if compact_match else 1)
|
||||
if not best or score > best[0]:
|
||||
best = (score, ep.id, mid)
|
||||
if best:
|
||||
return {"endpoint_id": best[1], "model": best[2]}
|
||||
return None
|
||||
|
||||
def _mask(k, v):
|
||||
return "••••• (set in panel)" if _is_secret(k) and v else v
|
||||
|
||||
if action == "list":
|
||||
s = load_settings()
|
||||
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
||||
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||
|
||||
elif action == "get":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if not key:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||
|
||||
elif action == "set":
|
||||
raw = args.get("key", "")
|
||||
value = args.get("value")
|
||||
if not raw:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
key = _resolve(raw)
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential/secret — for security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
||||
# have no safe scalar coercion — _coerce would pass a bare string
|
||||
# straight through and clobber the structure. Refuse them here; they're
|
||||
# edited in their dedicated panels. (reset/delete still restore the
|
||||
# default structure, which is safe.)
|
||||
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
|
||||
return {"response": f"'{key}' is a structured setting — edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
|
||||
try:
|
||||
value = _coerce(value, DEFAULT_SETTINGS[key])
|
||||
except (ValueError, TypeError):
|
||||
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
|
||||
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
|
||||
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
|
||||
s = load_settings()
|
||||
s[key] = value
|
||||
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
|
||||
resolved = _endpoint_model_from_cache(str(value))
|
||||
if resolved:
|
||||
prefix = key[:-6]
|
||||
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
|
||||
s[key] = resolved["model"]
|
||||
value = resolved["model"]
|
||||
save_settings(s)
|
||||
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
|
||||
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
|
||||
return {"response": f"Set {key} = {value}.", "exit_code": 0}
|
||||
|
||||
elif action == "delete" or action == "reset":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential — reset it in the panel.", "exit_code": 0}
|
||||
s = load_settings()
|
||||
s[key] = DEFAULT_SETTINGS[key]
|
||||
save_settings(s)
|
||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||
|
||||
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||
# (the global list read on every chat request) rather than
|
||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||
# "search" -> "web_search", "browser" -> "builtin_browser",
|
||||
# "documents" -> the document tool set, "memory" ->
|
||||
# manage_memory, etc.
|
||||
from src.settings import get_setting, save_settings, load_settings
|
||||
_ALIASES = {
|
||||
"shell": ["bash"],
|
||||
"terminal": ["bash"],
|
||||
"search": ["web_search", "web_fetch"],
|
||||
"web": ["web_search", "web_fetch"],
|
||||
"browser": ["builtin_browser"],
|
||||
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||
"memory": ["manage_memory"],
|
||||
"skills": ["manage_skills"],
|
||||
"images": ["generate_image"],
|
||||
"image": ["generate_image"],
|
||||
"tasks": ["manage_tasks"],
|
||||
"notes": ["manage_notes"],
|
||||
"calendar": ["manage_calendar"],
|
||||
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
|
||||
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool — closest analog
|
||||
}
|
||||
|
||||
if action == "list_tools":
|
||||
current = get_setting("disabled_tools", []) or []
|
||||
return {
|
||||
"response": (
|
||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||
"memory, skills, images, tasks, notes, calendar, email."
|
||||
),
|
||||
"disabled": list(current),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||
if not tool_name:
|
||||
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
|
||||
targets = _ALIASES.get(tool_name, [tool_name])
|
||||
|
||||
settings = load_settings()
|
||||
current = list(settings.get("disabled_tools") or [])
|
||||
before = set(current)
|
||||
if action == "disable_tool":
|
||||
for t in targets:
|
||||
if t not in current:
|
||||
current.append(t)
|
||||
else: # enable_tool
|
||||
current = [t for t in current if t not in targets]
|
||||
after = set(current)
|
||||
settings["disabled_tools"] = current
|
||||
save_settings(settings)
|
||||
|
||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
||||
changed = sorted(after.symmetric_difference(before))
|
||||
return {
|
||||
"response": (
|
||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
||||
),
|
||||
"changed": changed,
|
||||
"disabled": list(current),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_settings error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API call tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_api_call(content: str) -> Dict:
|
||||
"""Execute an API call to a registered integration."""
|
||||
from src.integrations import execute_api_call, load_integrations
|
||||
@@ -3452,7 +2669,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
|
||||
host_only = host.split("@", 1)[-1] if host else "localhost"
|
||||
endpoint_url = f"http://{host_only}:{int(port)}/v1"
|
||||
try:
|
||||
from src.tool_implementations import do_manage_endpoints # avoid forward ref issues
|
||||
from src.agent_tools.admin_tools import do_manage_endpoints # moved in #3629
|
||||
except Exception:
|
||||
do_manage_endpoints = None
|
||||
if do_manage_endpoints is not None:
|
||||
|
||||
+84
-1
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ src.constants which imports nothing from src). Adding a project import here
|
||||
will reintroduce the circular dependency that this module exists to break.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from src.constants import MAX_OUTPUT_CHARS
|
||||
|
||||
_mcp_manager = None
|
||||
@@ -37,3 +39,36 @@ def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
||||
if len(text) > limit:
|
||||
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
|
||||
return text
|
||||
|
||||
|
||||
def _parse_tool_args(content):
|
||||
"""Parse a tool-call argument blob.
|
||||
|
||||
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
||||
common `{"body": {...}}` envelope that smaller models emit when they
|
||||
read tool descriptions like "Body is JSON: {...}" literally and
|
||||
pass `body` as a field name rather than treating it as a noun.
|
||||
|
||||
Returns a dict on success, raises ValueError on bad JSON.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
args = json.loads(content) if content.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
raise ValueError(str(e))
|
||||
elif isinstance(content, dict):
|
||||
args = content
|
||||
else:
|
||||
args = {}
|
||||
# Unwrap {"body": {...}} envelope, but only if `body` is the sole key
|
||||
# and points at a dict. We don't want to clobber a legitimate `body`
|
||||
# field on tools where it's a real arg (e.g. send_email body text).
|
||||
if (
|
||||
isinstance(args, dict)
|
||||
and len(args) == 1
|
||||
and "body" in args
|
||||
and isinstance(args["body"], dict)
|
||||
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
||||
):
|
||||
args = args["body"]
|
||||
return args
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
@@ -1820,6 +1820,11 @@
|
||||
<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>
|
||||
</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 & 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
@@ -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()
|
||||
];
|
||||
|
||||
@@ -407,8 +407,44 @@ function _openVisionEditor(att, userMsgEl) {
|
||||
|
||||
// Tool call syntax patterns to strip from displayed text
|
||||
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
|
||||
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
|
||||
const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
|
||||
// Strip fenced tool-call blocks that look like structured invocations, not
|
||||
// regular code examples. The tool tags are NOT hard-coded here — they are the
|
||||
// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and
|
||||
// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is
|
||||
// thus the single source: the live-strip list can never drift from the backend
|
||||
// or miss a future tool (#3993). bash/python are carved out on purpose — they
|
||||
// are languages a user may legitimately have asked the model to show, not tool
|
||||
// invocations.
|
||||
//
|
||||
// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't
|
||||
// stripped — normally a sub-second window before the first stream. If the fetch
|
||||
// fails it stays null for the rest of the session (logged below), so live exec
|
||||
// fences won't be stripped until reload. Either way the backend already strips
|
||||
// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS),
|
||||
// so a reload always renders clean.
|
||||
let EXEC_FENCE_RE = null;
|
||||
const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']);
|
||||
|
||||
async function loadExecFenceRegex() {
|
||||
try {
|
||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const tags = (data.tools || [])
|
||||
.map((t) => t.id)
|
||||
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
||||
if (tags.length) {
|
||||
EXEC_FENCE_RE = new RegExp(
|
||||
'```(?:' + tags.join('|') + ')\\s*\\n[\\s\\S]*?```', 'gi'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null,
|
||||
// so this session won't strip live exec fences until reload (persisted path
|
||||
// stays clean regardless).
|
||||
console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err);
|
||||
}
|
||||
}
|
||||
loadExecFenceRegex();
|
||||
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
|
||||
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
|
||||
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
||||
@@ -853,7 +889,7 @@ export function roleTimestamp(when) {
|
||||
*/
|
||||
export function stripToolBlocks(text) {
|
||||
let cleaned = text.replace(TOOL_CALL_RE, '');
|
||||
cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||
cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
||||
cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
||||
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
||||
|
||||
+12
-3
@@ -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) {
|
||||
|
||||
@@ -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):' },
|
||||
{
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Registry wiring for the config/integration admin tools (#3629).
|
||||
|
||||
manage_endpoints/mcp/webhooks/tokens/settings moved from tool_implementations
|
||||
into agent_tools.admin_tools. These pin the registration + the single
|
||||
owner-threading adapter factory, without touching the DB (the do_* impls
|
||||
themselves are exercised by their own suites).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
from src.agent_tools.admin_tools import (
|
||||
ADMIN_TOOL_HANDLERS, _owner_adapter,
|
||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||
do_manage_tokens, do_manage_settings,
|
||||
)
|
||||
|
||||
_NAMES = ["manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "manage_settings"]
|
||||
|
||||
|
||||
def test_all_registered_in_tool_handlers():
|
||||
for n in _NAMES:
|
||||
assert n in TOOL_HANDLERS, f"{n} missing from TOOL_HANDLERS"
|
||||
assert n in ADMIN_TOOL_HANDLERS
|
||||
|
||||
|
||||
def test_re_exported_from_agent_tools():
|
||||
# Back-compat: importers that used `from src.agent_tools import do_manage_*`
|
||||
# keep working after the move.
|
||||
from src.agent_tools import ( # noqa: F401
|
||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||
do_manage_tokens, do_manage_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_adapter_threads_owner_from_ctx():
|
||||
seen = {}
|
||||
|
||||
async def _spy(content, owner):
|
||||
seen["content"] = content
|
||||
seen["owner"] = owner
|
||||
return {"response": "ok", "exit_code": 0}
|
||||
|
||||
handler = _owner_adapter(_spy)
|
||||
res = asyncio.run(handler('{"action":"list"}', {"owner": "alice", "session_id": "s1"}))
|
||||
assert res["exit_code"] == 0
|
||||
assert seen == {"content": '{"action":"list"}', "owner": "alice"}
|
||||
|
||||
|
||||
def test_owner_adapter_defaults_owner_to_none():
|
||||
captured = {}
|
||||
|
||||
async def _spy(content, owner):
|
||||
captured["owner"] = owner
|
||||
return {"exit_code": 0}
|
||||
|
||||
asyncio.run(_owner_adapter(_spy)("{}", {})) # ctx without owner
|
||||
assert captured["owner"] is None
|
||||
|
||||
|
||||
def test_parse_tool_args_lives_in_tool_utils_single_source():
|
||||
# The helper was de-duplicated into tool_utils; admin_tools imports it
|
||||
# from there rather than carrying its own copy.
|
||||
from src.tool_utils import _parse_tool_args
|
||||
from src.agent_tools import admin_tools, document_tools
|
||||
assert admin_tools._parse_tool_args is _parse_tool_args
|
||||
assert document_tools._parse_tool_args is _parse_tool_args
|
||||
assert _parse_tool_args('{"action":"add"}') == {"action": "add"}
|
||||
# body-envelope unwrap still works
|
||||
assert _parse_tool_args('{"body":{"action":"x"}}') == {"action": "x"}
|
||||
@@ -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"}
|
||||
@@ -86,7 +86,8 @@ def test_default_settings_registers_hard_max_key():
|
||||
def test_alias_map_registers_friendly_names():
|
||||
"""`manage_settings` should accept 'hard max' and friends."""
|
||||
from pathlib import Path
|
||||
src = Path("src/tool_implementations.py").read_text()
|
||||
# manage_settings (and its alias map) moved to agent_tools/admin_tools.py in #3629.
|
||||
src = Path("src/agent_tools/admin_tools.py").read_text()
|
||||
assert '"hard max": "agent_input_token_hard_max"' in src
|
||||
assert '"token budget cap": "agent_input_token_hard_max"' in src
|
||||
assert '"input budget cap": "agent_input_token_hard_max"' in src
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, is_prequantized
|
||||
|
||||
|
||||
def _8gb_vram_system():
|
||||
return {
|
||||
"has_gpu": True,
|
||||
"backend": "cuda",
|
||||
"gpu_name": "NVIDIA GeForce RTX 4060",
|
||||
"gpu_vram_gb": 8.0,
|
||||
"gpu_count": 1,
|
||||
"available_ram_gb": 32.0,
|
||||
"total_ram_gb": 32.0,
|
||||
}
|
||||
|
||||
|
||||
def test_gemma4_12b_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
|
||||
|
||||
|
||||
def test_gemma4_12b_has_gguf_source():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
entry = catalog["google/gemma-4-12B-it"]
|
||||
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
|
||||
repos = [s["repo"] for s in entry["gguf_sources"]]
|
||||
assert "unsloth/gemma-4-12B-it-GGUF" in repos
|
||||
|
||||
|
||||
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
||||
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
||||
names = [r["name"] for r in results]
|
||||
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it-qat-int4" in catalog
|
||||
assert "google/gemma-4-12B-it-qat-int8" in catalog
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_are_prequantized():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_have_no_gguf():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
|
||||
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Regression test for #3993 — live chat leaves executed tool fences visible.
|
||||
|
||||
The backend strips every fenced tool block (``src/tool_parsing.py`` builds its
|
||||
regex from the full ``TOOL_TAGS`` set), so a reloaded session renders cleanly.
|
||||
The live frontend path uses its own regex, ``EXEC_FENCE_RE`` in
|
||||
``static/js/chatRenderer.js``.
|
||||
|
||||
Originally that regex came from a hand-maintained subset, so any executable tool
|
||||
not in it — and every *future* tool added to ``TOOL_TAGS`` — left its executed
|
||||
fence lingering as a raw code block in the live bubble until reload. The fix
|
||||
makes ``TOOL_TAGS`` the single source: ``chatRenderer.js`` no longer hard-codes a
|
||||
tool list at all. It fetches the backend's authoritative set once from
|
||||
``GET /api/tools`` (which serves ``sorted(TOOL_TAGS)``) and builds
|
||||
``EXEC_FENCE_RE`` from it at load, minus ``bash``/``python`` (legitimate code
|
||||
examples a user may have asked the model to show). There is no second list to
|
||||
drift.
|
||||
|
||||
``chatRenderer.js`` pulls browser globals and can't be imported under node, so
|
||||
the behavioral tests exercise an equivalent Python regex built straight from the
|
||||
backend ``TOOL_TAGS`` — the same source the live regex now derives from — and
|
||||
source-level guards assert the frontend keeps no hard-coded list.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = Path("static/js/chatRenderer.js")
|
||||
_TOOLS_SRC = Path("src/agent_tools/__init__.py")
|
||||
_ROUTES_SRC = Path("routes/model_routes.py")
|
||||
|
||||
# Deliberately NOT stripped: legitimate code-example languages, not tool
|
||||
# invocations. Must match the carve-out in chatRenderer.js.
|
||||
_NON_STRIPPED = {"bash", "python"}
|
||||
|
||||
|
||||
def _tool_tags() -> set[str]:
|
||||
"""Extract the backend TOOL_TAGS set from src/agent_tools/__init__.py (source-level)."""
|
||||
source = _TOOLS_SRC.read_text(encoding="utf-8")
|
||||
m = re.search(r"TOOL_TAGS\s*=\s*\{(?P<body>.*?)\}", source, re.DOTALL)
|
||||
assert m, "TOOL_TAGS literal not found in src/agent_tools/__init__.py"
|
||||
return set(re.findall(r'"([a-z_]+)"', m.group("body")))
|
||||
|
||||
|
||||
def _exec_fence_regex() -> re.Pattern:
|
||||
"""Rebuild EXEC_FENCE_RE's behavior from the same source the live regex now
|
||||
derives from: the backend TOOL_TAGS (served via /api/tools) minus bash/python."""
|
||||
tags = _tool_tags() - _NON_STRIPPED
|
||||
assert tags, "TOOL_TAGS is empty"
|
||||
return re.compile(r"```(?:" + "|".join(sorted(tags)) + r")\s*\n[\s\S]*?```", re.IGNORECASE)
|
||||
|
||||
|
||||
def test_strips_executed_email_tool_fences():
|
||||
rx = _exec_fence_regex()
|
||||
# The exact shape the reporter observed lingering in the live bubble.
|
||||
text = 'Here are emails\n\n```list_emails\n{"max_results":10}\n```'
|
||||
assert rx.sub("", text).strip() == "Here are emails"
|
||||
|
||||
|
||||
def test_strips_every_named_email_tool_fence():
|
||||
rx = _exec_fence_regex()
|
||||
email_tools = [
|
||||
"list_email_accounts", "send_email", "list_emails", "read_email",
|
||||
"reply_to_email", "bulk_email", "archive_email", "delete_email",
|
||||
"mark_email_read",
|
||||
]
|
||||
for tool in email_tools:
|
||||
fence = f"```{tool}\n{{}}\n```"
|
||||
assert rx.sub("", fence).strip() == "", f"{tool} fence not stripped"
|
||||
|
||||
|
||||
def test_preserves_existing_web_search_stripping():
|
||||
rx = _exec_fence_regex()
|
||||
fence = '```web_search\n{"q":"x"}\n```'
|
||||
assert rx.sub("", fence).strip() == ""
|
||||
|
||||
|
||||
def test_does_not_strip_bash_or_python_code_examples():
|
||||
"""bash/python fences are deliberately excluded — they are legitimate code
|
||||
examples a user may have asked the model to show, not tool invocations."""
|
||||
rx = _exec_fence_regex()
|
||||
for lang in sorted(_NON_STRIPPED):
|
||||
example = f"```{lang}\nls -la\n```"
|
||||
assert rx.sub("", example) == example, f"{lang} example wrongly stripped"
|
||||
|
||||
|
||||
def test_frontend_keeps_no_hardcoded_tool_list():
|
||||
"""Root-cause guard for #3993: chatRenderer.js must NOT reintroduce a
|
||||
hand-maintained tool list. A hard-coded mirror of TOOL_TAGS silently drifts
|
||||
when a new tool is added — leaving its executed fence in the live bubble
|
||||
until reload. The live regex must instead be built from the backend's
|
||||
authoritative set fetched at runtime."""
|
||||
source = _SRC.read_text(encoding="utf-8")
|
||||
assert "EXEC_TOOL_TAGS" not in source, (
|
||||
"chatRenderer.js reintroduced a hard-coded EXEC_TOOL_TAGS list; the "
|
||||
"live-strip tags must come from GET /api/tools so TOOL_TAGS stays the "
|
||||
"single source (#3993)."
|
||||
)
|
||||
assert "/api/tools" in source, (
|
||||
"chatRenderer.js must fetch the tool set from /api/tools to build "
|
||||
"EXEC_FENCE_RE."
|
||||
)
|
||||
# The bash/python carve-out must survive the move to the runtime list.
|
||||
m = re.search(r"EXEC_FENCE_NON_TOOL\s*=\s*new Set\(\[(?P<body>.*?)\]\)", source, re.DOTALL)
|
||||
assert m, "bash/python carve-out (EXEC_FENCE_NON_TOOL) not found in chatRenderer.js"
|
||||
carve_out = set(re.findall(r"['\"]([a-z_]+)['\"]", m.group("body")))
|
||||
assert carve_out == _NON_STRIPPED, (
|
||||
f"EXEC_FENCE_NON_TOOL must carve out exactly {sorted(_NON_STRIPPED)}, "
|
||||
f"got {sorted(carve_out)}"
|
||||
)
|
||||
|
||||
|
||||
def test_api_tools_endpoint_serves_full_tool_tags():
|
||||
"""The frontend's single source is GET /api/tools. Guard that the endpoint
|
||||
serves the complete TOOL_TAGS set (sorted) — if it ever served a subset, the
|
||||
live-strip list would silently shrink with no second list to catch it."""
|
||||
source = _ROUTES_SRC.read_text(encoding="utf-8")
|
||||
assert re.search(r"for\s+tag\s+in\s+sorted\(\s*TOOL_TAGS\s*\)", source), (
|
||||
"GET /api/tools must iterate sorted(TOOL_TAGS) so the frontend's "
|
||||
"EXEC_FENCE_RE covers every executable tool (#3993)."
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -26,8 +26,8 @@ clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import McpServer
|
||||
import src.tool_implementations as ti
|
||||
from src.tool_implementations import _validate_mcp_command
|
||||
import src.agent_tools.admin_tools as ti # do_manage_mcp/get_mcp_manager moved here in the registry migration
|
||||
from src.agent_tools.admin_tools import _validate_mcp_command
|
||||
|
||||
_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import asyncio
|
||||
import json
|
||||
|
||||
import src.settings as settings_mod
|
||||
from src.tool_implementations import do_manage_settings
|
||||
from src.agent_tools.admin_tools import do_manage_settings
|
||||
|
||||
|
||||
def test_set_token_budget_is_not_refused_as_secret(monkeypatch):
|
||||
|
||||
@@ -8,7 +8,7 @@ from types import SimpleNamespace
|
||||
|
||||
def test_reconnect_passes_full_server_config():
|
||||
"""do_manage_mcp reconnect must pass name/transport/command/args/env/url."""
|
||||
from src.tool_implementations import do_manage_mcp
|
||||
from src.agent_tools.admin_tools import do_manage_mcp
|
||||
|
||||
fake_mcp = MagicMock()
|
||||
fake_mcp.disconnect_server = AsyncMock()
|
||||
@@ -28,7 +28,7 @@ def test_reconnect_passes_full_server_config():
|
||||
fake_db = MagicMock()
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = fake_srv
|
||||
|
||||
with patch("src.tool_implementations.get_mcp_manager", return_value=fake_mcp), \
|
||||
with patch("src.agent_tools.admin_tools.get_mcp_manager", return_value=fake_mcp), \
|
||||
patch("core.database.SessionLocal", return_value=fake_db):
|
||||
result = asyncio.run(do_manage_mcp(
|
||||
json.dumps({"action": "reconnect", "server_id": "srv-123"})
|
||||
|
||||
@@ -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):
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
@@ -821,7 +821,7 @@ async def test_webhook_tool_reuses_private_url_validation():
|
||||
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_src_db)
|
||||
|
||||
from src.tool_implementations import do_manage_webhooks
|
||||
from src.agent_tools.admin_tools import do_manage_webhooks
|
||||
|
||||
try:
|
||||
result = await do_manage_webhooks(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Behavior tests for src.app_helpers.serve_html_with_nonce.
|
||||
|
||||
Every caller of this helper serves a fixed, app-bundled template
|
||||
(index/login/backgrounds), never a client-supplied path. So a read failure —
|
||||
a missing file (broken deployment) or a permission/IO error — is a server
|
||||
fault, not a client "not found", and must surface as a logged 500 rather than
|
||||
hiding behind a 404 where 5xx alerting can't see it. These tests lock that
|
||||
intent (raised in the PR #4637 review).
|
||||
"""
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("starlette.responses")
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.app_helpers import serve_html_with_nonce
|
||||
|
||||
|
||||
def _request_with_nonce(nonce: str = ""):
|
||||
"""Minimal stand-in for a Starlette Request: only request.state.csp_nonce is read."""
|
||||
return types.SimpleNamespace(state=types.SimpleNamespace(csp_nonce=nonce))
|
||||
|
||||
|
||||
def test_missing_fixed_template_returns_500_not_404(tmp_path):
|
||||
missing = tmp_path / "does_not_exist.html"
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
serve_html_with_nonce(_request_with_nonce(), str(missing))
|
||||
assert exc_info.value.status_code == 500
|
||||
# Generic detail — no OS error string or absolute path leaked to the client.
|
||||
assert exc_info.value.detail == "Internal server error"
|
||||
|
||||
|
||||
def test_unreadable_template_returns_500(tmp_path):
|
||||
# A directory at the path makes open() raise an OSError subtype
|
||||
# (IsADirectoryError on POSIX, PermissionError on Windows) — same branch.
|
||||
a_dir = tmp_path / "a_dir.html"
|
||||
a_dir.mkdir()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
serve_html_with_nonce(_request_with_nonce(), str(a_dir))
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
def test_readable_template_injects_nonce(tmp_path):
|
||||
page = tmp_path / "page.html"
|
||||
page.write_text('<script nonce="{{CSP_NONCE}}">x</script>', encoding="utf-8")
|
||||
resp = serve_html_with_nonce(_request_with_nonce("nonce-abc"), str(page))
|
||||
assert resp.status_code == 200
|
||||
body = resp.body.decode("utf-8")
|
||||
assert "nonce-abc" in body
|
||||
assert "{{CSP_NONCE}}" not in body
|
||||
@@ -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"
|
||||
|
||||
@@ -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