mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-14 12:48:03 +00:00
Merge dev into main for testing
This commit is contained in:
@@ -35,6 +35,24 @@ def _ics_naive_dtstart(dt):
|
||||
return datetime(dt.year, dt.month, dt.day)
|
||||
return dt
|
||||
|
||||
|
||||
def _ensure_positive_duration(start_dt, end_dt, all_day):
|
||||
"""Clamp an imported event's end so it has a positive duration.
|
||||
|
||||
Some .ics exporters write a single-day all-day event with DTEND equal to
|
||||
DTSTART (treating DTEND as inclusive rather than the RFC 5545 exclusive
|
||||
bound). Stored verbatim that produces a zero-duration row, which the
|
||||
list_events overlap filter (dtstart < end AND dtend > start) silently
|
||||
drops — the event never appears on the calendar even though the web UI
|
||||
would otherwise show it. Normalize a non-positive end to the same default
|
||||
span used when DTEND is absent: one day for all-day events, one hour
|
||||
otherwise.
|
||||
"""
|
||||
if end_dt <= start_dt:
|
||||
return start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
|
||||
return end_dt
|
||||
|
||||
|
||||
# Single-user fallback identity. Used only when:
|
||||
# 1. The app is configured for single-user (no auth middleware), AND
|
||||
# 2. The request didn't resolve to an authenticated user.
|
||||
@@ -435,6 +453,20 @@ def _parse_dt(s: str) -> datetime:
|
||||
if t is not None:
|
||||
return base.replace(hour=t[0], minute=t[1])
|
||||
|
||||
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
||||
# (parity with parse_due_for_user, which handles these via the same form)
|
||||
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 + timedelta(days=1)
|
||||
elif word == "yesterday":
|
||||
base = today - timedelta(days=1)
|
||||
t = _parse_time(time_part)
|
||||
if t is not None:
|
||||
return base.replace(hour=t[0], minute=t[1])
|
||||
|
||||
# next <weekday> [at] TIME
|
||||
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||
m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower)
|
||||
@@ -1271,7 +1303,7 @@ def setup_calendar_routes() -> APIRouter:
|
||||
db.commit()
|
||||
db.refresh(target_cal)
|
||||
|
||||
imported = skipped = 0
|
||||
imported = skipped = repaired = 0
|
||||
for comp in cal_data.walk():
|
||||
if comp.name != "VEVENT":
|
||||
continue
|
||||
@@ -1307,6 +1339,18 @@ def setup_calendar_routes() -> APIRouter:
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
# An import predating the clamp below may have stored
|
||||
# this same event with a non-positive duration, which
|
||||
# the list_events overlap filter hides. Re-importing
|
||||
# lands here and would skip without touching that row,
|
||||
# so the event would stay invisible. Backfill the clamp
|
||||
# onto the stored row before skipping it.
|
||||
fixed_end = _ensure_positive_duration(
|
||||
existing.dtstart, existing.dtend, bool(existing.all_day)
|
||||
)
|
||||
if fixed_end != existing.dtend:
|
||||
existing.dtend = fixed_end
|
||||
repaired += 1
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
@@ -1340,6 +1384,8 @@ def setup_calendar_routes() -> APIRouter:
|
||||
else:
|
||||
end_dt = start_dt + timedelta(hours=1)
|
||||
|
||||
end_dt = _ensure_positive_duration(start_dt, end_dt, all_day)
|
||||
|
||||
ev = CalendarEvent(
|
||||
uid=uid_val,
|
||||
calendar_id=target_cal.id,
|
||||
@@ -1360,6 +1406,7 @@ def setup_calendar_routes() -> APIRouter:
|
||||
"ok": True,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"repaired": repaired,
|
||||
"calendar": cal_display,
|
||||
"calendar_id": target_cal.id,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ class ChatContext:
|
||||
# The chat route emits a doc_update SSE event for each before streaming
|
||||
# begins, so the editor pane switches to the new doc immediately.
|
||||
auto_opened_docs: list = field(default_factory=list)
|
||||
# Uploads attached to this user turn, resolved and owner-checked for the
|
||||
# agent's private context. This is not emitted to the browser.
|
||||
uploaded_files: list = field(default_factory=list)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||
@@ -366,6 +369,59 @@ async def preprocess(
|
||||
)
|
||||
|
||||
|
||||
def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[str]) -> list[dict]:
|
||||
"""Resolve current-turn upload IDs into a small tool-facing manifest.
|
||||
|
||||
The chat UI already sends attachment ids, and preprocessing inlines as much
|
||||
text as fits. Agent mode still needs a discoverable bridge for files whose
|
||||
content was truncated/omitted or when the model chooses file tools. Only
|
||||
owner-authorized uploads are included, and paths must remain inside the
|
||||
configured upload directory.
|
||||
"""
|
||||
if not att_ids or not upload_handler or not hasattr(upload_handler, "resolve_upload"):
|
||||
return []
|
||||
|
||||
def _read_file_can_open(path: str) -> bool:
|
||||
try:
|
||||
from src.tool_execution import _resolve_tool_path
|
||||
|
||||
return _resolve_tool_path(path) == os.path.realpath(path)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
manifest: list[dict] = []
|
||||
for att_id in att_ids:
|
||||
try:
|
||||
info = upload_handler.resolve_upload(str(att_id), owner=owner)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve upload %r for agent manifest", att_id, exc_info=True)
|
||||
continue
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
|
||||
path = info.get("path")
|
||||
if path:
|
||||
try:
|
||||
inside = True
|
||||
if hasattr(upload_handler, "_inside_upload_dir"):
|
||||
inside = bool(upload_handler._inside_upload_dir(path))
|
||||
elif hasattr(upload_handler, "inside_base_dir"):
|
||||
inside = bool(upload_handler.inside_base_dir(path))
|
||||
if not inside or not os.path.exists(path) or not _read_file_can_open(path):
|
||||
path = None
|
||||
except Exception:
|
||||
path = None
|
||||
|
||||
manifest.append({
|
||||
"id": info.get("id") or str(att_id),
|
||||
"name": info.get("name") or info.get("original_name") or str(att_id),
|
||||
"mime": info.get("mime", ""),
|
||||
"size": info.get("size", 0),
|
||||
"path": path,
|
||||
})
|
||||
return manifest
|
||||
|
||||
|
||||
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
||||
"""Add user message to session history and update session name.
|
||||
In incognito mode, still add to in-memory history (for conversation context)
|
||||
@@ -613,6 +669,11 @@ async def build_chat_context(
|
||||
# bearer-token chat requests use the token owner instead of the "api" sentinel.
|
||||
user = effective_user(request)
|
||||
uprefs = load_prefs_for_user(user)
|
||||
uploaded_files = build_uploaded_file_manifest(
|
||||
att_ids or [],
|
||||
getattr(chat_handler, "upload_handler", None),
|
||||
getattr(sess, "owner", None),
|
||||
)
|
||||
casual_low_signal = _is_casual_low_signal(message)
|
||||
|
||||
# Memory enabled?
|
||||
@@ -731,6 +792,7 @@ async def build_chat_context(
|
||||
preset=preset,
|
||||
preprocessed=preprocessed,
|
||||
auto_opened_docs=auto_opened_docs,
|
||||
uploaded_files=uploaded_files,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+11
-2
@@ -29,6 +29,7 @@ from routes.document_helpers import _owner_session_filter
|
||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||
from core.database import Document as DBDocument, ModelEndpoint
|
||||
from core.log_safety import redact_url
|
||||
from routes.research_routes import _resolve_research_endpoint
|
||||
from routes.model_routes import _visible_models
|
||||
from routes.chat_helpers import (
|
||||
@@ -930,7 +931,7 @@ def setup_chat_routes(
|
||||
if effective_do_research:
|
||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||
|
||||
# Clarification round: only for very short/vague queries on first research message.
|
||||
# Skip in compare mode — each pane is a fresh session, so every one would
|
||||
@@ -1254,7 +1255,14 @@ def setup_chat_routes(
|
||||
try:
|
||||
from src.settings import get_setting
|
||||
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
||||
_tool_budget = int(get_setting("agent_max_tool_calls", 0))
|
||||
# Per-message tool budget from settings; guard defensively in
|
||||
# case settings.json was hand-edited to a non-numeric value
|
||||
# (the HTTP admin endpoint validates, but direct edits bypass
|
||||
# it). 0 = unlimited, matching auth_routes set_settings().
|
||||
try:
|
||||
_tool_budget = int(get_setting("agent_max_tool_calls", 0))
|
||||
except (TypeError, ValueError):
|
||||
_tool_budget = 0
|
||||
# Per-message round cap from settings; clamp defensively in
|
||||
# case settings.json was hand-edited to a bad value.
|
||||
try:
|
||||
@@ -1289,6 +1297,7 @@ def setup_chat_routes(
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
forced_tools=_forced_tools,
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
|
||||
+45
-10
@@ -15,6 +15,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import require_authenticated_request, require_user
|
||||
from src.tool_implementations import do_manage_notes
|
||||
from src.constants import COOKBOOK_STATE_FILE
|
||||
@@ -109,6 +110,20 @@ def _scope_owner_all(request: Request, required: set[str]) -> str:
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
|
||||
"""Authorize a Codex cookbook route.
|
||||
|
||||
For API-token callers, enforce the given scope set.
|
||||
For cookie-session callers, additionally require admin privileges
|
||||
because cookbook surfaces expose host topology, task logs, tmux
|
||||
commands, and model-serving controls.
|
||||
"""
|
||||
owner = _scope_owner(request, allowed)
|
||||
if not getattr(request.state, "api_token", False):
|
||||
require_admin(request)
|
||||
return owner
|
||||
|
||||
|
||||
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||
if router is None:
|
||||
return None
|
||||
@@ -118,6 +133,18 @@ def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp_pagination(offset: Any, limit: Any, *, default_limit: int = 50, max_limit: int = 50) -> tuple[int, int]:
|
||||
try:
|
||||
parsed_offset = int(0 if offset in (None, "") else offset)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "Invalid offset")
|
||||
try:
|
||||
parsed_limit = int(default_limit if limit in (None, "") else limit)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "Invalid limit")
|
||||
return max(0, parsed_offset), max(1, min(parsed_limit, max_limit))
|
||||
|
||||
|
||||
def setup_codex_routes(
|
||||
email_router: APIRouter | None = None,
|
||||
memory_router: APIRouter | None = None,
|
||||
@@ -425,10 +452,18 @@ def setup_codex_routes(
|
||||
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
||||
if documents_library_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
return await _as_owner(
|
||||
offset, limit = _clamp_pagination(offset, limit)
|
||||
result = await _as_owner(
|
||||
request, owner, documents_library_endpoint,
|
||||
request, search, language, sort, offset, limit, archived,
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
docs = result.get("documents")
|
||||
total = result.get("total")
|
||||
if isinstance(docs, list) and isinstance(total, int):
|
||||
next_offset = offset + len(docs)
|
||||
result["next_offset"] = next_offset if next_offset < total else None
|
||||
return result
|
||||
|
||||
@router.get("/documents/{doc_id}")
|
||||
async def codex_documents_get(request: Request, doc_id: str):
|
||||
@@ -532,14 +567,14 @@ def setup_codex_routes(
|
||||
|
||||
@router.get("/cookbook/tasks")
|
||||
async def codex_cookbook_tasks(request: Request):
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
tasks = state.get("tasks") or []
|
||||
return {"tasks": [_redact_task(t) for t in tasks]}
|
||||
|
||||
@router.get("/cookbook/servers")
|
||||
async def codex_cookbook_servers(request: Request):
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
servers = state.get("env", {}).get("servers") or []
|
||||
# Strip ssh creds / passwords; keep only what's needed to pick a host.
|
||||
@@ -558,7 +593,7 @@ def setup_codex_routes(
|
||||
|
||||
@router.get("/cookbook/output/{session_id}")
|
||||
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
# Defensive: session_id must be the tmux-style id we issue
|
||||
# (`serve-XXXX` / `cookbook-XXXX` / `queue-XXXX`); anything else
|
||||
# would let the agent run arbitrary `tmux capture-pane` targets.
|
||||
@@ -600,7 +635,7 @@ def setup_codex_routes(
|
||||
|
||||
@router.post("/cookbook/serve")
|
||||
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
# Wraps /api/model/serve with the SAME validation the UI uses.
|
||||
# _validate_serve_cmd (called inside model_serve) rejects shell
|
||||
# metachars and requires the leading binary to be in the
|
||||
@@ -639,7 +674,7 @@ def setup_codex_routes(
|
||||
|
||||
@router.post("/cookbook/stop/{session_id}")
|
||||
async def codex_cookbook_stop(request: Request, session_id: str):
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
||||
raise HTTPException(400, "Invalid session id")
|
||||
@@ -659,7 +694,7 @@ def setup_codex_routes(
|
||||
"""List cached models on a configured server (or local if host is omitted).
|
||||
Mirrors `list_cached_models` from the chat agent so external agents have
|
||||
the same inventory view before deciding what to serve/download."""
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
# Hit /api/model/cached internally, with the same modelDirs the chat
|
||||
# agent's list_cached_models would resolve from cookbook state.
|
||||
state = _read_cookbook_state()
|
||||
@@ -721,7 +756,7 @@ def setup_codex_routes(
|
||||
"""List saved serve presets (model + host + port + launch cmd).
|
||||
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
|
||||
body — the user's saved preset usually has the working cmd already."""
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
presets = state.get("presets") or []
|
||||
out = []
|
||||
@@ -741,7 +776,7 @@ def setup_codex_routes(
|
||||
async def codex_cookbook_serve_preset(request: Request, name: str):
|
||||
"""Launch a saved preset by name. Reuses the working cmd + host the
|
||||
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"[A-Za-z0-9 _.:@\-]+", name):
|
||||
raise HTTPException(400, "Invalid preset name")
|
||||
@@ -793,7 +828,7 @@ def setup_codex_routes(
|
||||
cookbook tracking. Needed when serve_model rejects a cmd and the
|
||||
agent falls back to direct ssh — without adoption the session is
|
||||
invisible to the UI. Body: {tmux_session, model, host?, port?}."""
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
norm = dict(body or {})
|
||||
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
||||
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
|
||||
from core.log_safety import redact_url
|
||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -149,6 +150,14 @@ def _vunesc(value: str) -> str:
|
||||
|
||||
def _parse_vcards(text: str) -> List[Dict]:
|
||||
"""Parse a stream of vCards into dicts with name, email, phone."""
|
||||
# Unfold RFC 6350 3.2 line folding first: a CRLF/LF followed by a single
|
||||
# space or tab is a continuation of the previous logical line. Real
|
||||
# CardDAV servers (Radicale, iCloud, Apple/Google) fold long EMAIL / FN /
|
||||
# PHOTO lines, and splitting on raw newlines without unfolding dropped the
|
||||
# continuation (e.g. "...@example\n .com" lost the ".com"), truncating the
|
||||
# email/name.
|
||||
text = re.sub(r"\r\n[ \t]", "", text or "")
|
||||
text = re.sub(r"\n[ \t]", "", text)
|
||||
contacts = []
|
||||
for block in re.split(r"BEGIN:VCARD", text):
|
||||
if not block.strip():
|
||||
@@ -689,15 +698,24 @@ def _delete_contact(uid: str) -> bool:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.delete(url, auth=auth, timeout=10)
|
||||
if r.status_code in (200, 204):
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
if r.status_code == 404:
|
||||
# Resource not found at the resolved URL. With href resolution
|
||||
# this should be rare (genuinely already deleted). Invalidate
|
||||
# the cache and report success so the UI doesn't keep a ghost.
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
|
||||
if r.status_code in (200, 204, 404):
|
||||
# Invalidate cache so the next fetch sees the server truth.
|
||||
_contact_cache["fetched_at"] = None
|
||||
# Verify: force a fresh fetch and check the UID is actually gone.
|
||||
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
|
||||
# lives at a different resource URL — the DELETE missed it but
|
||||
# we'd silently report success. This check catches that.
|
||||
fresh = _fetch_contacts(force=True)
|
||||
still_there = any(c.get("uid") == uid for c in fresh)
|
||||
if still_there:
|
||||
logger.warning(
|
||||
f"CardDAV DELETE reported success for {uid} "
|
||||
f"but UID still present after re-fetch — "
|
||||
f"resource URL may differ from {redact_url(url)}"
|
||||
)
|
||||
return False
|
||||
if r.status_code == 404:
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
|
||||
return True
|
||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
|
||||
+27
-10
@@ -561,7 +561,7 @@ def _bash_squote(v: str) -> str:
|
||||
# Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve.
|
||||
# Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper.
|
||||
_SERVE_CMD_ALLOWLIST = {
|
||||
"vllm", "llama-server", "llama_server", "llama.cpp", "ollama",
|
||||
"vllm", "llama-server", "llama-server.exe", "llama_server", "llama.cpp", "ollama",
|
||||
"python", "python3",
|
||||
"sglang", "lmdeploy",
|
||||
"node", "npx",
|
||||
@@ -577,6 +577,16 @@ _SERVE_CMD_ALLOWLIST = {
|
||||
_GGUF_PRELUDE_RE = re.compile(
|
||||
r'^MODEL_FILE=\$\([^\n]*?\)\s*&&\s*\{[^{}]*\}\s*\|\|\s*\{[^{}]*\}\s*&&\s*'
|
||||
)
|
||||
_SAFE_SUBSHELL_TEXT = r"[^'\n;&|`$()<>]+"
|
||||
_SAFE_SUBSHELL_DQ_HOME_PATH = r'"\$HOME/[^"\n;&|`()<>]*"'
|
||||
_SAFE_PRINTF_SUBSHELL_RE = re.compile(
|
||||
rf"^\$\(printf[ \t]+%s[ \t]+(?:'{_SAFE_SUBSHELL_TEXT}'|\$\{{HOME\}}'/{_SAFE_SUBSHELL_TEXT}')\)$"
|
||||
)
|
||||
_SAFE_FIND_MMPROJ_SUBSHELL_RE = re.compile(
|
||||
rf"^\$\(find[ \t]+(?:'{_SAFE_SUBSHELL_TEXT}'|{_SAFE_SUBSHELL_DQ_HOME_PATH}|{_SAFE_SUBSHELL_TEXT})"
|
||||
r"[ \t]+-iname[ \t]+'mmproj\*\.gguf'"
|
||||
r"(?:[ \t]+2>/dev/null)?[ \t]*\|[ \t]*sort[ \t]*\|[ \t]*head[ \t]+-1\)$"
|
||||
)
|
||||
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
||||
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
||||
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
||||
@@ -677,6 +687,13 @@ def _check_serve_binary(seg: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _is_safe_serve_subshell(subshell: str) -> bool:
|
||||
return bool(
|
||||
_SAFE_PRINTF_SUBSHELL_RE.fullmatch(subshell)
|
||||
or _SAFE_FIND_MMPROJ_SUBSHELL_RE.fullmatch(subshell)
|
||||
)
|
||||
|
||||
|
||||
def _validate_serve_cmd(v: str | None) -> str | None:
|
||||
"""Reject serve commands that aren't in the allowlist or contain shell metachars.
|
||||
|
||||
@@ -708,15 +725,15 @@ def _validate_serve_cmd(v: str | None) -> str | None:
|
||||
_check_serve_binary(part.strip())
|
||||
return v
|
||||
|
||||
# Otherwise: a single invocation — no shell metacharacters allowed.
|
||||
# Temporarily replace safe $(printf %s ...) expressions with a placeholder
|
||||
# to avoid triggering the metacharacter/command-injection checks.
|
||||
cleaned_v = v
|
||||
printf_matches = list(re.finditer(r"\$\(\s*printf\s+%s\s+([^\n()]*?)\)", v))
|
||||
for match in printf_matches:
|
||||
inner = match.group(1)
|
||||
if not any(c in inner for c in (";", "&&", "||", "$(", "`")):
|
||||
cleaned_v = cleaned_v.replace(match.group(0), "/placeholder/safe/path.gguf")
|
||||
# Otherwise: a single invocation — no shell metacharacters allowed. Replace
|
||||
# only the exact command substitutions emitted by the Cookbook UI:
|
||||
# $(printf %s 'safe-path') and the mmproj lookup
|
||||
# $(find <path> -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1).
|
||||
def _replace_safe_subshell(match: re.Match[str]) -> str:
|
||||
subshell = match.group(0)
|
||||
return "/placeholder/safe/path" if _is_safe_serve_subshell(subshell) else subshell
|
||||
|
||||
cleaned_v = re.sub(r"\$\([^()]*\)", _replace_safe_subshell, v)
|
||||
|
||||
# (`$(` was the original intent; bare `$` is fine for shell-safe paths.)
|
||||
if any(c in cleaned_v for c in (";", "&&", "||", "$(")):
|
||||
|
||||
@@ -74,6 +74,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return "stored"
|
||||
return f"{value[:4]}...{value[-4:]}"
|
||||
|
||||
def _client_host_platform() -> str:
|
||||
return "windows" if IS_WINDOWS else ""
|
||||
|
||||
def _decrypt_secret(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
@@ -246,11 +249,15 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""Return cookbook state without raw secrets for browser clients."""
|
||||
_strip_task_secrets(state)
|
||||
env = state.get("env") if isinstance(state, dict) else None
|
||||
if isinstance(state, dict) and not isinstance(env, dict):
|
||||
env = {}
|
||||
state["env"] = env
|
||||
if isinstance(env, dict):
|
||||
token = _decrypt_secret(env.get("hfToken"))
|
||||
env.pop("hfToken", None)
|
||||
env["hfTokenConfigured"] = bool(token)
|
||||
env["hfTokenMasked"] = _mask_secret(token)
|
||||
env["hostPlatform"] = _client_host_platform()
|
||||
return state
|
||||
|
||||
def _state_for_storage(state, on_disk=None):
|
||||
@@ -269,6 +276,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
env.pop("hfToken", None)
|
||||
env.pop("hfTokenMasked", None)
|
||||
env.pop("hfTokenConfigured", None)
|
||||
env.pop("hostPlatform", None)
|
||||
return state
|
||||
|
||||
def _load_stored_hf_token() -> str:
|
||||
@@ -1528,6 +1536,10 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# shell resolves the bundled python3/hf, mirroring the download flow.
|
||||
if not remote:
|
||||
runner_lines.append(_local_tooling_path_export(sys.executable))
|
||||
if local_windows:
|
||||
# Detached Git Bash runs do not always inherit recently edited
|
||||
# user PATH entries from the already-running Odysseus process.
|
||||
runner_lines.append('export PATH="$HOME/bin:$HOME/llama.cpp/build-cuda/bin/Release:$HOME/llama.cpp/build/bin/Release:$HOME/llama.cpp/build/bin/Debug:$HOME/llama.cpp/build/bin:$PATH"')
|
||||
runner_lines.append("export FLASHINFER_DISABLE_VERSION_CHECK=1")
|
||||
if req.hf_token:
|
||||
runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'")
|
||||
@@ -1542,7 +1554,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(_HF_TOKEN_STATUS_SNIPPET)
|
||||
handled_ollama_serve = False
|
||||
# Auto-install inference engine if missing
|
||||
if "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||
local_windows_llama_cmd = local_windows and ("llama_cpp" in req.cmd or "llama-server" in req.cmd)
|
||||
if ("llama_cpp" in req.cmd or "llama-server" in req.cmd) and not local_windows_llama_cmd:
|
||||
# Prefer the NATIVE llama-server binary — its minja templating
|
||||
# renders modern GGUF chat templates that the Python bindings'
|
||||
# Jinja2 rejects (do_tojson ensure_ascii). Build it once from
|
||||
@@ -2445,8 +2458,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
try:
|
||||
return _state_for_client(json.loads(_cookbook_state_path.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
return _state_for_client({})
|
||||
return _state_for_client({})
|
||||
|
||||
@router.post("/api/cookbook/state")
|
||||
async def save_cookbook_state(request: Request):
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from core.database import Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -79,6 +80,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
|
||||
the session join for any not-yet-backfilled legacy row.
|
||||
"""
|
||||
if user is None:
|
||||
if _auth_disabled():
|
||||
return # Single-user / no-auth mode: allow access
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if doc.owner is not None:
|
||||
if doc.owner != user:
|
||||
@@ -105,7 +108,6 @@ def _owner_session_filter(q, user):
|
||||
by the time this filter is live there are no NULL-owner rows to leak;
|
||||
we therefore match the owner strictly for authenticated callers."""
|
||||
if not user:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
if user == "" or _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
||||
from sqlalchemy import case, func, or_
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -388,7 +388,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not user:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if not _auth_disabled():
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v2 review HIGH-9: raise 403 explicitly when the caller
|
||||
# can't see this session, instead of returning [] which the
|
||||
# UI treats identically to "no docs" and silently masks
|
||||
|
||||
+21
-2
@@ -40,6 +40,16 @@ from src.secret_storage import decrypt as _decrypt
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailNotConfiguredError(RuntimeError):
|
||||
"""Raised when an IMAP operation is attempted on an account that has no
|
||||
inbox configured (e.g. a send-only / SMTP-only account).
|
||||
|
||||
Subclasses RuntimeError so existing broad ``except Exception`` handlers
|
||||
keep working; callers that want to treat "no inbox" as an empty result
|
||||
rather than a failure can catch this type specifically.
|
||||
"""
|
||||
|
||||
|
||||
def _xoauth2_raw(user: str, access_token: str) -> str:
|
||||
"""The SASL XOAUTH2 initial-response string (unencoded).
|
||||
|
||||
@@ -225,8 +235,9 @@ def _strip_think(text: str) -> str:
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
from src.text_helpers import strip_think as _central, _THINK_CLOSED_RE, _THINK_OPEN_RE, _THINK_TAG_RE
|
||||
had_think = bool(_THINK_CLOSED_RE.search(text) or _THINK_OPEN_RE.search(text) or _THINK_TAG_RE.search(text))
|
||||
from src.text_helpers import strip_think as _central, _THINK_TAG_RE
|
||||
# Single linear tag check; the old closed/open `.search()` calls could ReDoS.
|
||||
had_think = bool(_THINK_TAG_RE.search(text))
|
||||
return _central(text, prose=had_think, prompt_echo=True)
|
||||
|
||||
|
||||
@@ -1008,6 +1019,14 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
|
||||
# `timeout` is overridable so short-lived callers (e.g. the service-health
|
||||
# probe) can impose a tighter budget than the default IMAP timeout.
|
||||
cfg = _get_email_config(account_id, owner=owner)
|
||||
# Send-only (SMTP-only) account: no IMAP host means there is no inbox to
|
||||
# read. Bail out with a clear, typed error instead of handing an empty
|
||||
# host to imaplib — IMAP4("", 993) silently dials localhost:993 and fails
|
||||
# with a confusing "[Errno 111] Connection refused" on every inbox poll.
|
||||
if not cfg.get("imap_host"):
|
||||
raise EmailNotConfiguredError(
|
||||
f"IMAP is not configured for account {cfg.get('account_name') or 'default'!r}"
|
||||
)
|
||||
# Connection mode:
|
||||
# STARTTLS on → plain + upgrade
|
||||
# STARTTLS off + port 993 → implicit SSL (IMAPS)
|
||||
|
||||
+36
-18
@@ -44,6 +44,17 @@ from routes.email_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
|
||||
# fenced-block strip leaves nothing usable. Runs on model output influenced by
|
||||
# untrusted email bodies, so it must not backtrack: the object content class is
|
||||
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
|
||||
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
|
||||
# (CodeQL py/redos #198).
|
||||
_CAL_ACTION_ARRAY_RE = re.compile(
|
||||
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _extract_json_array_from_text(text: str):
|
||||
"""Return the last valid JSON array embedded in model output, if any."""
|
||||
@@ -603,7 +614,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
cal_extract = _strip_think(_raw_original)
|
||||
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
|
||||
if not cal_extract and _raw_original:
|
||||
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL))
|
||||
matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
|
||||
if matches:
|
||||
cal_extract = matches[-1].group()
|
||||
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
||||
@@ -735,23 +746,30 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
logger.warning(f"[cal-extract] no JSON array found on raw={cal_extract[:200]!r}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
||||
# Record successfully parsed results so we don't re-LLM
|
||||
# no-op emails. If the model returned no parseable JSON,
|
||||
# leave it uncached so a future run can retry.
|
||||
try:
|
||||
if _cal_parse_ok:
|
||||
_cc = _sql3.connect(SCHEDULED_DB)
|
||||
_cc.execute(
|
||||
"INSERT OR REPLACE INTO email_calendar_extractions "
|
||||
"(message_id, owner, uid, event_uids, events_created, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
json.dumps(_cal_event_uids), _cal_run_count, datetime.utcnow().isoformat())
|
||||
)
|
||||
_cc.commit()
|
||||
_cc.close()
|
||||
_cal_existing.add(message_id)
|
||||
except Exception as ce:
|
||||
logger.debug(f"Could not cache calendar extraction: {ce}")
|
||||
else:
|
||||
# Record successfully parsed results so we don't re-LLM
|
||||
# no-op emails. Transient LLM failures are retried on
|
||||
# the next poll run.
|
||||
try:
|
||||
if _cal_parse_ok:
|
||||
_cc = _sql3.connect(SCHEDULED_DB)
|
||||
_cc.execute(
|
||||
"INSERT OR REPLACE INTO email_calendar_extractions "
|
||||
"(message_id, owner, uid, event_uids, events_created, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
message_id,
|
||||
account_owner or "",
|
||||
uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
json.dumps(_cal_event_uids),
|
||||
_cal_run_count,
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
_cc.commit()
|
||||
_cc.close()
|
||||
_cal_existing.add(message_id)
|
||||
except Exception as ce:
|
||||
logger.debug(f"Could not cache calendar extraction: {ce}")
|
||||
|
||||
if need_urgent:
|
||||
try:
|
||||
|
||||
+42
-8
@@ -46,6 +46,7 @@ from routes.email_helpers import (
|
||||
_send_smtp_message, _smtp_security_mode,
|
||||
_IMAP_TIMEOUT_SECONDS, _open_imap_connection,
|
||||
make_oauth_state, verify_oauth_state,
|
||||
EmailNotConfiguredError,
|
||||
_imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder,
|
||||
_extract_attachment_text, _list_attachments_from_msg, _has_visible_attachments, _is_likely_signature_image_attachment,
|
||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||
@@ -64,6 +65,21 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
||||
EMAIL_READ_ATTACHMENT_VERSION = 2
|
||||
|
||||
|
||||
def _coerce_port(value, default):
|
||||
"""Coerce a user-supplied port to int.
|
||||
|
||||
Returns ``(port, error)``. A missing or blank value yields ``default``; a
|
||||
non-numeric value yields ``(None, message)`` so callers can return a clean
|
||||
error instead of letting ``int()`` raise and surface as an HTTP 500.
|
||||
"""
|
||||
if value in (None, ""):
|
||||
return default, None
|
||||
try:
|
||||
return int(value), None
|
||||
except (TypeError, ValueError):
|
||||
return None, f"Invalid port {value!r}; must be a whole number"
|
||||
|
||||
|
||||
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
|
||||
aliases = [owner or ""]
|
||||
try:
|
||||
@@ -1757,6 +1773,11 @@ def setup_email_routes():
|
||||
"updated_at": datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
}
|
||||
except EmailNotConfiguredError:
|
||||
# Send-only (SMTP-only) account: there is no inbox to read, so the
|
||||
# poll returns an empty list instead of a per-minute error. SMTP
|
||||
# send is unaffected.
|
||||
return {"emails": [], "total": 0, "folder": folder, "offset": offset}
|
||||
except Exception as e:
|
||||
conn_ok = False
|
||||
logger.error(f"Failed to list emails: {e}")
|
||||
@@ -4440,6 +4461,12 @@ def setup_email_routes():
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return {"ok": False, "error": "name required"}
|
||||
imap_port, port_err = _coerce_port(data.get("imap_port"), 993)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = EmailAccount(
|
||||
@@ -4448,13 +4475,13 @@ def setup_email_routes():
|
||||
is_default=bool(data.get("is_default", False)),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
imap_host=(data.get("imap_host") or "").strip(),
|
||||
imap_port=int(data.get("imap_port") or 993),
|
||||
imap_port=imap_port,
|
||||
imap_user=(data.get("imap_user") or "").strip(),
|
||||
imap_password=_enc(data.get("imap_password") or ""),
|
||||
imap_starttls=bool(data.get("imap_starttls", True)),
|
||||
smtp_host=(data.get("smtp_host") or "").strip(),
|
||||
smtp_port=int(data.get("smtp_port") or 465),
|
||||
smtp_security=_smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": data.get("smtp_port") or 465}),
|
||||
smtp_port=smtp_port,
|
||||
smtp_security=_smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": smtp_port}),
|
||||
smtp_user=(data.get("smtp_user") or "").strip(),
|
||||
smtp_password=_enc(data.get("smtp_password") or ""),
|
||||
from_address=(data.get("from_address") or "").strip(),
|
||||
@@ -4498,7 +4525,10 @@ def setup_email_routes():
|
||||
setattr(row, key, (data[key] or "").strip())
|
||||
for key in ("imap_port", "smtp_port"):
|
||||
if data.get(key) not in (None, ""):
|
||||
setattr(row, key, int(data[key]))
|
||||
port, port_err = _coerce_port(data.get(key), None)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
setattr(row, key, port)
|
||||
if "smtp_security" in data:
|
||||
row.smtp_security = _smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": data.get("smtp_port") or row.smtp_port})
|
||||
for key in ("imap_starttls", "enabled"):
|
||||
@@ -4602,12 +4632,14 @@ def setup_email_routes():
|
||||
smtp_result = None
|
||||
|
||||
imap_host = (body.get("imap_host") or "").strip()
|
||||
imap_port = int(body.get("imap_port") or 993)
|
||||
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
|
||||
imap_user = (body.get("imap_user") or "").strip()
|
||||
imap_pass = body.get("imap_password") or ""
|
||||
imap_starttls = bool(body.get("imap_starttls"))
|
||||
|
||||
if not (imap_host and imap_user and imap_pass):
|
||||
if imap_port_err:
|
||||
imap_result = {"ok": False, "error": imap_port_err}
|
||||
elif not (imap_host and imap_user and imap_pass):
|
||||
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
||||
else:
|
||||
# Connection mode resolution:
|
||||
@@ -4634,8 +4666,10 @@ def setup_email_routes():
|
||||
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
|
||||
|
||||
smtp_host = (body.get("smtp_host") or "").strip()
|
||||
if smtp_host:
|
||||
smtp_port = int(body.get("smtp_port") or 465)
|
||||
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
||||
if smtp_host and smtp_port_err:
|
||||
smtp_result = {"ok": False, "error": smtp_port_err}
|
||||
elif smtp_host:
|
||||
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
||||
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
||||
smtp_pass = body.get("smtp_password") or imap_pass
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Gallery route domain package (slice 2a, #4082/#4071).
|
||||
|
||||
Contains gallery_routes.py and gallery_helpers.py, migrated from the flat
|
||||
routes/ directory. Backward-compat shims at routes/gallery_routes.py and
|
||||
routes/gallery_helpers.py re-export from here.
|
||||
"""
|
||||
@@ -0,0 +1,144 @@
|
||||
"""gallery_helpers.py — extracted helpers, models, and small utilities.
|
||||
|
||||
Imported by gallery_routes.py."""
|
||||
|
||||
"""Gallery routes — browsable library for photos and AI-generated images."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import GalleryImage
|
||||
from src.auth_helpers import _auth_disabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- Request schemas ----
|
||||
|
||||
class GalleryPatch(BaseModel):
|
||||
tags: Optional[str] = None
|
||||
favorite: Optional[bool] = None
|
||||
album_id: Optional[str] = None
|
||||
|
||||
|
||||
# ---- EXIF extraction ----
|
||||
|
||||
def _extract_exif(content: bytes) -> dict:
|
||||
"""Extract EXIF metadata from image bytes. Returns dict of fields."""
|
||||
result = {"width": None, "height": None}
|
||||
try:
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(content))
|
||||
# Read the raw EXIF before any transpose: exif_transpose strips the
|
||||
# orientation tag and with it the parsed EXIF view.
|
||||
exif = img._getexif() if hasattr(img, '_getexif') else None
|
||||
|
||||
# Record DISPLAY dimensions (EXIF-rotated), matching upload_handler.
|
||||
# A phone photo with Orientation 6/8 is stored landscape but shown
|
||||
# portrait, so the raw width/height swap the aspect ratio.
|
||||
try:
|
||||
from PIL import ImageOps
|
||||
img = ImageOps.exif_transpose(img) or img
|
||||
except Exception:
|
||||
pass
|
||||
result["width"] = img.width
|
||||
result["height"] = img.height
|
||||
|
||||
if not exif:
|
||||
return result
|
||||
|
||||
# EXIF tag IDs
|
||||
# 271=Make, 272=Model, 306=DateTime, 36867=DateTimeOriginal
|
||||
# 34853=GPSInfo
|
||||
result["camera_make"] = str(exif.get(271, "")).strip() or None
|
||||
result["camera_model"] = str(exif.get(272, "")).strip() or None
|
||||
|
||||
# Date taken
|
||||
for tag_id in (36867, 36868, 306): # DateTimeOriginal, DateTimeDigitized, DateTime
|
||||
raw = exif.get(tag_id)
|
||||
if raw:
|
||||
try:
|
||||
result["taken_at"] = datetime.strptime(str(raw).strip(), "%Y:%m:%d %H:%M:%S")
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# GPS
|
||||
gps_info = exif.get(34853)
|
||||
if gps_info and isinstance(gps_info, dict):
|
||||
try:
|
||||
def _to_deg(vals):
|
||||
d, m, s = [float(v) for v in vals]
|
||||
return d + m / 60 + s / 3600
|
||||
if 2 in gps_info and 4 in gps_info:
|
||||
lat = _to_deg(gps_info[2])
|
||||
lng = _to_deg(gps_info[4])
|
||||
if gps_info.get(1) == 'S': lat = -lat
|
||||
if gps_info.get(3) == 'W': lng = -lng
|
||||
result["gps_lat"] = f"{lat:.6f}"
|
||||
result["gps_lng"] = f"{lng:.6f}"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
# User-visible failure (photo loses metadata): surface at WARNING
|
||||
# and record on the result so the upload endpoint can pass it back.
|
||||
logger.warning(f"EXIF extraction failed: {e}")
|
||||
result["exif_error"] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
def _image_to_dict(img: GalleryImage, session_name: str = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": img.id,
|
||||
"filename": img.filename,
|
||||
"url": f"/api/generated-image/{img.filename}",
|
||||
"prompt": img.prompt,
|
||||
"model": img.model,
|
||||
"size": img.size,
|
||||
"quality": img.quality,
|
||||
"tags": img.tags or "",
|
||||
"ai_tags": img.ai_tags or "",
|
||||
"user_tags": img.tags or "",
|
||||
"session_id": img.session_id,
|
||||
"session_name": session_name,
|
||||
"album_id": img.album_id,
|
||||
"is_active": img.is_active,
|
||||
"favorite": img.favorite or False,
|
||||
"taken_at": img.taken_at.isoformat() if img.taken_at else None,
|
||||
"camera": f"{img.camera_make or ''} {img.camera_model or ''}".strip() or None,
|
||||
"gps": {"lat": img.gps_lat, "lng": img.gps_lng} if img.gps_lat else None,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"file_size": img.file_size,
|
||||
"created_at": img.created_at.isoformat() if img.created_at else None,
|
||||
"updated_at": img.updated_at.isoformat() if img.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _owner_filter(q, user, model_cls=GalleryImage):
|
||||
"""Apply owner filtering to a gallery query.
|
||||
|
||||
``get_current_user`` returns None both in auth-disabled single-user mode
|
||||
and when auth is enabled but no current user was resolved. Preserve the
|
||||
single-user behavior, but fail closed for auth-enabled null-user states.
|
||||
"""
|
||||
if user is not None:
|
||||
return q.filter(model_cls.owner == user)
|
||||
if _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
|
||||
|
||||
|
||||
def _human_size(nbytes):
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if abs(nbytes) < 1024:
|
||||
return f"{nbytes:.1f} {unit}"
|
||||
nbytes /= 1024
|
||||
return f"{nbytes:.1f} PB"
|
||||
File diff suppressed because it is too large
Load Diff
+10
-141
@@ -1,145 +1,14 @@
|
||||
"""gallery_helpers.py — extracted helpers, models, and small utilities.
|
||||
"""Backward-compat shim - canonical location is routes/gallery/gallery_helpers.py.
|
||||
|
||||
Imported by gallery_routes.py."""
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.gallery_helpers``, ``from routes.gallery_helpers import X``,
|
||||
``importlib.import_module("routes.gallery_helpers")``, and
|
||||
``monkeypatch.setattr(routes.gallery_helpers, ...)`` all operate on the same
|
||||
object. Keeps existing import paths working after slice 2a (#4082/#4071).
|
||||
"""
|
||||
|
||||
"""Gallery routes — browsable library for photos and AI-generated images."""
|
||||
import sys as _sys
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from routes.gallery import gallery_helpers as _canonical # noqa: F401
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import GalleryImage
|
||||
from src.auth_helpers import _auth_disabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- Request schemas ----
|
||||
|
||||
class GalleryPatch(BaseModel):
|
||||
tags: Optional[str] = None
|
||||
favorite: Optional[bool] = None
|
||||
album_id: Optional[str] = None
|
||||
|
||||
|
||||
# ---- EXIF extraction ----
|
||||
|
||||
def _extract_exif(content: bytes) -> dict:
|
||||
"""Extract EXIF metadata from image bytes. Returns dict of fields."""
|
||||
result = {"width": None, "height": None}
|
||||
try:
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(content))
|
||||
# Read the raw EXIF before any transpose: exif_transpose strips the
|
||||
# orientation tag and with it the parsed EXIF view.
|
||||
exif = img._getexif() if hasattr(img, '_getexif') else None
|
||||
|
||||
# Record DISPLAY dimensions (EXIF-rotated), matching upload_handler.
|
||||
# A phone photo with Orientation 6/8 is stored landscape but shown
|
||||
# portrait, so the raw width/height swap the aspect ratio.
|
||||
try:
|
||||
from PIL import ImageOps
|
||||
img = ImageOps.exif_transpose(img) or img
|
||||
except Exception:
|
||||
pass
|
||||
result["width"] = img.width
|
||||
result["height"] = img.height
|
||||
|
||||
if not exif:
|
||||
return result
|
||||
|
||||
# EXIF tag IDs
|
||||
# 271=Make, 272=Model, 306=DateTime, 36867=DateTimeOriginal
|
||||
# 34853=GPSInfo
|
||||
result["camera_make"] = str(exif.get(271, "")).strip() or None
|
||||
result["camera_model"] = str(exif.get(272, "")).strip() or None
|
||||
|
||||
# Date taken
|
||||
for tag_id in (36867, 36868, 306): # DateTimeOriginal, DateTimeDigitized, DateTime
|
||||
raw = exif.get(tag_id)
|
||||
if raw:
|
||||
try:
|
||||
result["taken_at"] = datetime.strptime(str(raw).strip(), "%Y:%m:%d %H:%M:%S")
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# GPS
|
||||
gps_info = exif.get(34853)
|
||||
if gps_info and isinstance(gps_info, dict):
|
||||
try:
|
||||
def _to_deg(vals):
|
||||
d, m, s = [float(v) for v in vals]
|
||||
return d + m / 60 + s / 3600
|
||||
if 2 in gps_info and 4 in gps_info:
|
||||
lat = _to_deg(gps_info[2])
|
||||
lng = _to_deg(gps_info[4])
|
||||
if gps_info.get(1) == 'S': lat = -lat
|
||||
if gps_info.get(3) == 'W': lng = -lng
|
||||
result["gps_lat"] = f"{lat:.6f}"
|
||||
result["gps_lng"] = f"{lng:.6f}"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
# User-visible failure (photo loses metadata): surface at WARNING
|
||||
# and record on the result so the upload endpoint can pass it back.
|
||||
logger.warning(f"EXIF extraction failed: {e}")
|
||||
result["exif_error"] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
def _image_to_dict(img: GalleryImage, session_name: str = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": img.id,
|
||||
"filename": img.filename,
|
||||
"url": f"/api/generated-image/{img.filename}",
|
||||
"prompt": img.prompt,
|
||||
"caption": img.caption or "",
|
||||
"model": img.model,
|
||||
"size": img.size,
|
||||
"quality": img.quality,
|
||||
"tags": img.tags or "",
|
||||
"ai_tags": img.ai_tags or "",
|
||||
"user_tags": img.tags or "",
|
||||
"session_id": img.session_id,
|
||||
"session_name": session_name,
|
||||
"album_id": img.album_id,
|
||||
"is_active": img.is_active,
|
||||
"favorite": img.favorite or False,
|
||||
"taken_at": img.taken_at.isoformat() if img.taken_at else None,
|
||||
"camera": f"{img.camera_make or ''} {img.camera_model or ''}".strip() or None,
|
||||
"gps": {"lat": img.gps_lat, "lng": img.gps_lng} if img.gps_lat else None,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"file_size": img.file_size,
|
||||
"created_at": img.created_at.isoformat() if img.created_at else None,
|
||||
"updated_at": img.updated_at.isoformat() if img.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _owner_filter(q, user, model_cls=GalleryImage):
|
||||
"""Apply owner filtering to a gallery query.
|
||||
|
||||
``get_current_user`` returns None both in auth-disabled single-user mode
|
||||
and when auth is enabled but no current user was resolved. Preserve the
|
||||
single-user behavior, but fail closed for auth-enabled null-user states.
|
||||
"""
|
||||
if user is not None:
|
||||
return q.filter(model_cls.owner == user)
|
||||
if _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
|
||||
|
||||
|
||||
def _human_size(nbytes):
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if abs(nbytes) < 1024:
|
||||
return f"{nbytes:.1f} {unit}"
|
||||
nbytes /= 1024
|
||||
return f"{nbytes:.1f} PB"
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
+12
-1923
File diff suppressed because it is too large
Load Diff
+56
-18
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import StreamingResponse
|
||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||
from core.log_safety import redact_url as _redact_url_for_log
|
||||
from core.middleware import require_admin
|
||||
from src.constants import COOKBOOK_STATE_FILE
|
||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||
@@ -606,6 +607,10 @@ _NON_CHAT_EXACT_PREFIXES = (
|
||||
|
||||
def _is_chat_model(model_id: str) -> bool:
|
||||
"""Return True if the model ID looks like a chat/completions-capable model."""
|
||||
if not isinstance(model_id, str):
|
||||
# Non-compliant upstreams can return non-string IDs (e.g. int/None);
|
||||
# treat them as chat-capable rather than crashing on .lower().
|
||||
return True
|
||||
mid = model_id.lower()
|
||||
for prefix in _NON_CHAT_PREFIXES:
|
||||
if mid.startswith(prefix):
|
||||
@@ -666,18 +671,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
|
||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
|
||||
def _redact_url_for_log(url: str) -> str:
|
||||
"""Return a URL safe for logs by removing userinfo and query/fragment."""
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||
except Exception:
|
||||
return "<endpoint>"
|
||||
|
||||
|
||||
def _is_discovery_only_provider(provider: str) -> bool:
|
||||
return provider == "chatgpt-subscription"
|
||||
|
||||
@@ -821,6 +814,41 @@ def _is_loading_model_response(resp: Any) -> bool:
|
||||
|
||||
|
||||
|
||||
def _openai_model_ids(data: Any) -> List[str]:
|
||||
"""Extract OpenAI-style model IDs.
|
||||
|
||||
Accepts both standard ``{"data": [{"id": ...}]}`` responses and bare
|
||||
``[{"id": ...}]`` lists returned by some OpenAI-compatible providers.
|
||||
Tolerates non-dict/non-list bodies and non-string IDs, returning only
|
||||
non-empty string IDs.
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("data")
|
||||
else:
|
||||
items = None
|
||||
return [m["id"] for m in (items or [])
|
||||
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]]
|
||||
|
||||
|
||||
def _ollama_model_names(data: Any) -> List[str]:
|
||||
"""Extract native-Ollama model names (``{"models": [{"name"|"model": ...}]}``).
|
||||
|
||||
Same tolerance as :func:`_openai_model_ids`: a non-dict body or non-string
|
||||
value is skipped rather than crashing, preserving name-then-model precedence.
|
||||
"""
|
||||
items = data.get("models") if isinstance(data, dict) else None
|
||||
out: List[str] = []
|
||||
for m in (items or []):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
v = m.get("name") or m.get("model")
|
||||
if isinstance(v, str) and v:
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||
@@ -843,7 +871,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
models = _openai_model_ids(data)
|
||||
if models:
|
||||
return models
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -865,10 +893,10 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
models = _openai_model_ids(data)
|
||||
# Ollama format: {"models": [{"name": "model-name"}]}
|
||||
if not models:
|
||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
||||
models = _ollama_model_names(data)
|
||||
if models:
|
||||
# Z.AI coding plan omits some working models from /models;
|
||||
# append curated-only entries for that endpoint only.
|
||||
@@ -894,9 +922,9 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||
except Exception as e:
|
||||
if api_key:
|
||||
logger.warning(f"Failed to probe {url} with API key: {e}")
|
||||
logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
|
||||
return []
|
||||
logger.warning(f"Failed to probe {url}: {e}")
|
||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||
|
||||
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||
# the OpenAI-compatible /v1/models path is unavailable.
|
||||
@@ -907,7 +935,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
||||
models = _ollama_model_names(data)
|
||||
if models:
|
||||
return [m for m in models if _is_chat_model(m)]
|
||||
except Exception as e:
|
||||
@@ -2169,6 +2197,16 @@ def setup_model_routes(model_discovery):
|
||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||
model = (_user_prefs.get("default_model") or "").strip()
|
||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||
# If user has no personal default, fall back to global default
|
||||
# But only based on the "share_defaults_with_users" flag
|
||||
# (only if share_defaults_with_users is enabled)
|
||||
if settings.get("share_defaults_with_users", False):
|
||||
if not ep_id:
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
if not model:
|
||||
model = settings.get("default_model", "")
|
||||
if not _fallbacks:
|
||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||
else:
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
model = settings.get("default_model", "")
|
||||
|
||||
@@ -335,10 +335,11 @@ async def dispatch_reminder(
|
||||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||
logger.info(
|
||||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
||||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
||||
f"from={from_addr!r} recipient={recipient!r} "
|
||||
f"account_name={cfg.get('account_name')!r}"
|
||||
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||
note_id, owner,
|
||||
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||
bool(from_addr), bool(recipient),
|
||||
)
|
||||
missing = []
|
||||
if not cfg.get("smtp_host"):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Preset routes — /api/presets GET, /api/presets/custom POST, user templates CRUD."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Any, List
|
||||
@@ -102,7 +103,7 @@ def setup_preset_routes(preset_manager) -> APIRouter:
|
||||
try:
|
||||
model_spec = data.get("model") or ""
|
||||
user = effective_user(request)
|
||||
url, model, headers = _resolve_model(model_spec, owner=user)
|
||||
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=user)
|
||||
result = await llm_call_async(url, model, messages, temperature=0.8, max_tokens=500, headers=headers)
|
||||
return {"success": True, "prompt": result.strip()}
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Research route domain package (slice 2b, #4082/#4071).
|
||||
|
||||
Contains research_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/research_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Research background task routes — /api/research/*."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model-name substrings that are NOT chat/generation models — research must
|
||||
# never pick these as its model. An OpenAI-style endpoint often lists
|
||||
# `text-embedding-ada-002` etc. first in its model list, which is why research
|
||||
# was failing with "Cannot reach model 'text-embedding-ada-002'".
|
||||
_NON_CHAT_MODEL = (
|
||||
"text-embedding", "embedding", "tts-", "whisper", "dall-e",
|
||||
"moderation", "rerank", "reranker", "clip", "stable-diffusion",
|
||||
)
|
||||
|
||||
|
||||
def _first_chat_model(models) -> str:
|
||||
"""First model that isn't an embedding/tts/etc. — falls back to models[0]."""
|
||||
for m in (models or []):
|
||||
if not any(p in str(m).lower() for p in _NON_CHAT_MODEL):
|
||||
return m
|
||||
return (models[0] if models else "")
|
||||
|
||||
|
||||
def _resolve_research_endpoint(sess, owner: Optional[str] = None) -> tuple:
|
||||
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
||||
owner = owner or 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
|
||||
|
||||
|
||||
def _owned_enabled_endpoint(db, owner, endpoint_id=None):
|
||||
"""An enabled ModelEndpoint VISIBLE to `owner` (their own rows + legacy
|
||||
null-owner "shared" rows), optionally narrowed to a specific endpoint_id;
|
||||
None if nothing visible matches.
|
||||
|
||||
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
||||
owner = private, "the model picker only shows the endpoint to that user") and
|
||||
holds a decrypted `api_key`. /api/research/start feeds the resolved row's
|
||||
api_key + base_url into research_handler.start_research(llm_endpoint=,
|
||||
llm_headers=), so an UNSCOPED lookup — by the caller-supplied endpoint_id, or
|
||||
via the bare first-enabled fallback — would let a research-privileged user
|
||||
spend ANOTHER user's API key/quota and reach whatever internal base_url they
|
||||
configured. Mirrors webhook_routes._first_enabled_endpoint and
|
||||
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
||||
legacy mode).
|
||||
"""
|
||||
from src.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if endpoint_id:
|
||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||
return owner_filter(q, ModelEndpoint, owner).first()
|
||||
|
||||
|
||||
def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
"""Resolve a ModelEndpoint row into (chat_url, model, headers).
|
||||
|
||||
Mirrors endpoint_resolver.resolve_endpoint's provider-auth handling for
|
||||
panel-selected research endpoints. ChatGPT Subscription endpoints keep
|
||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||
)
|
||||
|
||||
try:
|
||||
base, api_key = resolve_model_endpoint_runtime(ep, owner=owner)
|
||||
except Exception as e:
|
||||
logger.warning("Could not resolve endpoint credentials for research: %s", e)
|
||||
return None
|
||||
|
||||
ep_model = (model or "").strip()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
if not ep_model:
|
||||
return None
|
||||
return build_chat_url(base), ep_model, build_headers(api_key, base)
|
||||
|
||||
|
||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
router = APIRouter(tags=["research"])
|
||||
|
||||
def _require_user(request: Request) -> str:
|
||||
"""All research endpoints require an authenticated user. Research
|
||||
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
||||
block anonymous access. Multi-tenant deploys should additionally
|
||||
verify the session belongs to this user."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
if _auth_disabled():
|
||||
return ""
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
def _validate_session_id(session_id: str) -> None:
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
raise HTTPException(400, "Invalid session ID format")
|
||||
|
||||
def _owns_in_memory(session_id: str, user: str) -> bool:
|
||||
"""Ownership check for an in-flight (in-memory) research task.
|
||||
Falls back to the on-disk JSON if the task has already finished."""
|
||||
entry = research_handler._active_tasks.get(session_id)
|
||||
if entry is not None:
|
||||
return entry.get("owner", "") == user
|
||||
# Task no longer in memory — check the persisted JSON.
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")).get("owner") == user
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@router.get("/api/research/active")
|
||||
async def research_active(request: Request):
|
||||
"""List all currently active (running) research tasks."""
|
||||
user = _require_user(request)
|
||||
active = []
|
||||
for sid, entry in research_handler._active_tasks.items():
|
||||
# SECURITY: only show this user's running tasks.
|
||||
if entry.get("owner", "") != user:
|
||||
continue
|
||||
if entry.get("status") == "running":
|
||||
active.append({
|
||||
"session_id": sid,
|
||||
"query": entry.get("query", ""),
|
||||
"status": "running",
|
||||
"progress": entry.get("progress", {}),
|
||||
"started_at": entry.get("started_at", 0),
|
||||
})
|
||||
return {"active": active}
|
||||
|
||||
@router.get("/api/research/status/{session_id}")
|
||||
async def research_status(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return status
|
||||
|
||||
@router.post("/api/research/cancel/{session_id}")
|
||||
async def research_cancel(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
cancelled = research_handler.cancel_research(session_id)
|
||||
return {"cancelled": cancelled}
|
||||
|
||||
@router.post("/api/research/result/{session_id}")
|
||||
async def research_result(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research result available")
|
||||
result = research_handler.get_result(session_id)
|
||||
if result is None:
|
||||
raise HTTPException(404, "No research result available")
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
raw_findings = research_handler.get_raw_findings(session_id) or []
|
||||
research_handler.clear_result(session_id)
|
||||
return {"result": result, "sources": sources, "raw_findings": raw_findings}
|
||||
|
||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||
Use BEFORE returning any data or mutating the file."""
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
||||
except Exception:
|
||||
raise HTTPException(404, "Research not found")
|
||||
if owner != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
|
||||
@router.get("/api/research/report/{session_id}")
|
||||
async def research_report(session_id: str, request: Request):
|
||||
"""Serve the visual HTML report for a completed research session."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
_assert_owns_research(session_id, user)
|
||||
logger.info(f"Visual report requested for session {session_id}")
|
||||
try:
|
||||
html_content = research_handler.get_report_html(session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Visual report generation error: {e}", exc_info=True)
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
if html_content is None:
|
||||
logger.warning(f"No report data found for session {session_id}")
|
||||
raise HTTPException(404, "No visual report available for this session")
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
class HideImageRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
@router.post("/api/research/{session_id}/hide-image")
|
||||
async def research_hide_image(session_id: str, body: HideImageRequest, request: Request):
|
||||
"""Mark an image URL as hidden for this research's visual report.
|
||||
Persisted to the research JSON so subsequent /report renders skip it."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
_assert_owns_research(session_id, user)
|
||||
ok = research_handler.hide_image(session_id, body.url)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/research/{session_id}/unhide-images")
|
||||
async def research_unhide_images(session_id: str, request: Request):
|
||||
"""Clear the hidden-images list for a research session."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
_assert_owns_research(session_id, user)
|
||||
ok = research_handler.unhide_all_images(session_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/api/research/library")
|
||||
async def research_library(
|
||||
request: Request,
|
||||
search: Optional[str] = Query(None),
|
||||
sort: str = Query("recent"),
|
||||
limit: int = Query(50),
|
||||
archived: bool = Query(False),
|
||||
):
|
||||
user = _require_user(request)
|
||||
"""List all completed research for the Library panel."""
|
||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||
items = []
|
||||
for p in data_dir.glob("*.json"):
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
# SECURITY: only show research belonging to this user. Legacy
|
||||
# JSONs without an `owner` field are hidden — auth was the only
|
||||
# gate before, so every user saw every other user's reports.
|
||||
if d.get("owner") != user:
|
||||
continue
|
||||
# Archived view shows ONLY archived reports; default hides them.
|
||||
if bool(d.get("archived")) != archived:
|
||||
continue
|
||||
query = d.get("query", "")
|
||||
if search and search.lower() not in query.lower():
|
||||
continue
|
||||
sources = d.get("sources", [])
|
||||
items.append({
|
||||
"id": p.stem,
|
||||
"query": query,
|
||||
"category": d.get("category") or "",
|
||||
"source_count": len(sources),
|
||||
"status": d.get("status", "done"),
|
||||
"duration": d.get("stats", {}).get("Duration", ""),
|
||||
"rounds": d.get("stats", {}).get("Rounds", ""),
|
||||
"started_at": d.get("started_at", 0),
|
||||
"completed_at": d.get("completed_at", 0),
|
||||
"archived": bool(d.get("archived")),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort
|
||||
if sort == "recent":
|
||||
items.sort(key=lambda x: x["completed_at"] or 0, reverse=True)
|
||||
elif sort == "oldest":
|
||||
items.sort(key=lambda x: x["completed_at"] or 0)
|
||||
elif sort == "most-messages":
|
||||
items.sort(key=lambda x: x["source_count"], reverse=True)
|
||||
elif sort == "alpha":
|
||||
items.sort(key=lambda x: x["query"].lower())
|
||||
|
||||
return {"research": items[:limit], "total": len(items)}
|
||||
|
||||
@router.get("/api/research/detail/{session_id}")
|
||||
async def research_detail(session_id: str, request: Request):
|
||||
"""Return the full JSON for a single research result — sources,
|
||||
summary, stats — used by the Library preview panel."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to read research: {e}")
|
||||
# SECURITY: 404 (not 403) so we don't leak that the report exists.
|
||||
if data.get("owner") != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return data
|
||||
|
||||
@router.post("/api/research/{session_id}/archive")
|
||||
async def research_archive(session_id: str, request: Request, archived: bool = Query(True)):
|
||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("owner") != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
data["archived"] = bool(archived)
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to update research: {e}")
|
||||
return {"ok": True, "id": session_id, "archived": bool(archived)}
|
||||
|
||||
@router.delete("/api/research/{session_id}")
|
||||
async def research_delete(session_id: str, request: Request):
|
||||
"""Delete a research result from disk."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||
json_path = data_dir / f"{session_id}.json"
|
||||
deleted = False
|
||||
if json_path.exists():
|
||||
# SECURITY: verify ownership before letting the caller delete it.
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if data.get("owner") != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(404, "Research not found")
|
||||
json_path.unlink()
|
||||
deleted = True
|
||||
return {"deleted": deleted}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Panel endpoints — launch research without a chat session
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class ResearchStartRequest(BaseModel):
|
||||
query: str
|
||||
# max_rounds=0 means "Auto" — let the AI decide when to stop, capped at 20.
|
||||
max_rounds: int = Field(default=0, ge=0, le=20)
|
||||
search_provider: Optional[str] = None
|
||||
endpoint_id: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
max_time: int = Field(default=300, ge=60, le=1800)
|
||||
extraction_timeout: Optional[int] = Field(default=None, ge=15, le=3600)
|
||||
extraction_concurrency: Optional[int] = Field(default=None, ge=1, le=12)
|
||||
category: Optional[str] = None
|
||||
|
||||
@router.post("/api/research/start")
|
||||
async def research_start(body: ResearchStartRequest, request: Request):
|
||||
"""Launch a research job from the dedicated panel."""
|
||||
from src.auth_helpers import require_privilege
|
||||
user = require_privilege(request, "can_use_research")
|
||||
if user == INTERNAL_TOOL_USER:
|
||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||
try:
|
||||
privs = auth_mgr.get_privileges(tool_owner) or {}
|
||||
if not privs.get("can_use_research", True):
|
||||
raise HTTPException(403, f"Your account is not allowed to can use research.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
user = tool_owner
|
||||
session_id = f"rp-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
if body.endpoint_id:
|
||||
from src.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Owner-scoped: never resolve another user's private endpoint
|
||||
# (and its decrypted api_key / internal base_url). A scoped miss
|
||||
# reads as 404 so the endpoint's existence isn't revealed.
|
||||
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found or disabled")
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
|
||||
if not resolved:
|
||||
raise HTTPException(400, "Endpoint is not configured with a usable model.")
|
||||
ep_url, ep_model, ep_headers = resolved
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("research", owner=user)
|
||||
if not ep_url:
|
||||
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", owner=user)
|
||||
if not ep_url:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
||||
if not ep_url:
|
||||
from src.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Owner-scoped first-enabled fallback: the caller's own rows
|
||||
# + legacy null-owner shared rows only — never borrow another
|
||||
# user's private endpoint/api_key. Same fix as the
|
||||
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
ep_url, ep_model, ep_headers = resolved
|
||||
finally:
|
||||
db.close()
|
||||
if not ep_url:
|
||||
raise HTTPException(400, "No endpoints configured. Add one in Settings first.")
|
||||
if body.model:
|
||||
ep_model = body.model
|
||||
|
||||
# max_rounds=0 → "Auto", let AI decide; pass 20 as the safety cap.
|
||||
effective_max_rounds = body.max_rounds if body.max_rounds > 0 else 20
|
||||
research_handler.start_research(
|
||||
session_id=session_id,
|
||||
query=body.query,
|
||||
llm_endpoint=ep_url,
|
||||
llm_model=ep_model,
|
||||
max_time=body.max_time,
|
||||
llm_headers=ep_headers,
|
||||
max_rounds=effective_max_rounds,
|
||||
search_provider=body.search_provider or None,
|
||||
category=body.category or None,
|
||||
extraction_timeout=body.extraction_timeout,
|
||||
extraction_concurrency=body.extraction_concurrency,
|
||||
owner=user,
|
||||
)
|
||||
return {"session_id": session_id, "status": "running", "query": body.query}
|
||||
|
||||
@router.get("/api/research/stream/{session_id}")
|
||||
async def research_stream(session_id: str, request: Request):
|
||||
"""SSE stream of research progress events."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
async def _generate():
|
||||
last_progress = None
|
||||
while True:
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
return
|
||||
st = status.get("status", "")
|
||||
progress = status.get("progress", {})
|
||||
if progress != last_progress:
|
||||
last_progress = progress
|
||||
yield f"data: {json.dumps({**progress, 'status': st})}\n\n"
|
||||
if st != "running":
|
||||
final = {'status': st, 'final': True}
|
||||
task = research_handler._active_tasks.get(session_id, {})
|
||||
if st == "error" and task.get("result"):
|
||||
final['error'] = str(task["result"])[:500]
|
||||
yield f"data: {json.dumps(final)}\n\n"
|
||||
return
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@router.post("/api/research/result-peek/{session_id}")
|
||||
async def research_result_peek(session_id: str, request: Request):
|
||||
"""Get research result without clearing it (for panel use)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
result = research_handler.get_result(session_id)
|
||||
if result is None:
|
||||
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if p.exists():
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"result": d.get("result", ""),
|
||||
"sources": d.get("sources", []),
|
||||
"raw_findings": d.get("raw_findings", []),
|
||||
"category": d.get("category") or "",
|
||||
}
|
||||
raise HTTPException(404, "No research result available")
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
raw_findings = research_handler.get_raw_findings(session_id) or []
|
||||
return {"result": result, "sources": sources, "raw_findings": raw_findings, "category": ""}
|
||||
|
||||
@router.post("/api/research/spinoff/{session_id}")
|
||||
async def research_spinoff(session_id: str, request: Request):
|
||||
"""Create a new chat session pre-seeded with this research as context.
|
||||
|
||||
Reads the persisted research result + sources for `session_id`, creates
|
||||
a fresh session (inheriting endpoint/model/headers from the source
|
||||
session if available, otherwise from the resolved chat endpoint), and
|
||||
injects a single system message containing the report and sources so
|
||||
the user can ask follow-up questions in a clean conversation.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
# SECURITY: gate on ownership before reading the persisted research —
|
||||
# otherwise any authenticated user could spin off (and thereby read)
|
||||
# another user's report by guessing its session ID. Mirrors every other
|
||||
# endpoint in this file (see result_peek above).
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
if session_manager is None:
|
||||
raise HTTPException(500, "session_manager not configured")
|
||||
|
||||
# Load research data — prefer in-memory result, fall back to disk
|
||||
result = research_handler.get_result(session_id)
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
query = ""
|
||||
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if path.exists():
|
||||
try:
|
||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not result:
|
||||
result = disk.get("result")
|
||||
if not sources:
|
||||
sources = disk.get("sources", []) or []
|
||||
query = disk.get("query", "") or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read research JSON for spinoff: {e}")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(404, "No research result available for this session")
|
||||
|
||||
# Inherit endpoint/model/headers from the source session when possible.
|
||||
# For panel-launched research (rp-* IDs), there is no chat session, so
|
||||
# fall back through the same chain as /api/research/start: research →
|
||||
# utility → first enabled endpoint in the DB.
|
||||
ep_url, ep_model, ep_headers = "", "", {}
|
||||
try:
|
||||
src_sess = session_manager.get_session(session_id)
|
||||
ep_url = src_sess.endpoint_url or ""
|
||||
ep_model = src_sess.model or ""
|
||||
ep_headers = dict(src_sess.headers or {})
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _merge(r_url, r_model, r_headers):
|
||||
nonlocal ep_url, ep_model, ep_headers
|
||||
if not ep_url and r_url:
|
||||
ep_url = r_url
|
||||
if not ep_model and r_model:
|
||||
ep_model = r_model
|
||||
if not ep_headers and r_headers:
|
||||
ep_headers = dict(r_headers)
|
||||
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("chat", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("research", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("utility", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not ep_url or not ep_model:
|
||||
raise HTTPException(400, "No endpoint configured — add one in Settings first")
|
||||
|
||||
# Create new session
|
||||
new_sid = str(uuid.uuid4())
|
||||
|
||||
title_query = (query or "research").strip()
|
||||
if len(title_query) > 60:
|
||||
title_query = title_query[:57] + "…"
|
||||
new_name = f"Follow-up: {title_query}"
|
||||
|
||||
new_sess = session_manager.create_session(
|
||||
session_id=new_sid,
|
||||
name=new_name,
|
||||
endpoint_url=ep_url,
|
||||
model=ep_model,
|
||||
rag=False,
|
||||
owner=user,
|
||||
)
|
||||
if ep_headers:
|
||||
new_sess.headers = ep_headers
|
||||
session_manager.save_sessions()
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", user)
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
|
||||
# Build the priming system message — report only, no sources injected.
|
||||
# The user can open the visual report for source details; keeping sources
|
||||
# out of the chat context saves tokens and avoids the AI fabricating
|
||||
# citations.
|
||||
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
primer = (
|
||||
f"[Research context — {date_str}]\n\n"
|
||||
f"The user previously ran a deep research investigation. Use the "
|
||||
f"report below as your primary knowledge base when answering "
|
||||
f"follow-up questions. If the user asks something not covered, "
|
||||
f"say so plainly rather than guessing.\n\n"
|
||||
f"=== ORIGINAL QUERY ===\n{query or '(not recorded)'}\n\n"
|
||||
f"=== REPORT ===\n{result}"
|
||||
)
|
||||
|
||||
from core.models import ChatMessage
|
||||
new_sess.add_message(ChatMessage(
|
||||
role="system",
|
||||
content=primer,
|
||||
metadata={"research_spinoff_from": session_id},
|
||||
))
|
||||
session_manager.save_sessions()
|
||||
|
||||
return {
|
||||
"session_id": new_sid,
|
||||
"name": new_name,
|
||||
"source_count": len(sources),
|
||||
}
|
||||
|
||||
return router
|
||||
+13
-674
@@ -1,678 +1,17 @@
|
||||
"""Research background task routes — /api/research/*."""
|
||||
"""Backward-compat shim — canonical location is routes/research/research_routes.py.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.research_routes``, ``from routes.research_routes import X``,
|
||||
``importlib.import_module("routes.research_routes")``, and
|
||||
``monkeypatch.setattr("routes.research_routes.ATTR", ...)`` (string-targeted
|
||||
patch used by ``test_research_owner_scope_routes.py``) all operate on the
|
||||
*same* object the application actually uses. Keeps existing import paths
|
||||
working after slice 2b (#4082/#4071). Source-introspection tests read the
|
||||
canonical file by path.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
import sys as _sys
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
from routes.research import research_routes as _canonical # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model-name substrings that are NOT chat/generation models — research must
|
||||
# never pick these as its model. An OpenAI-style endpoint often lists
|
||||
# `text-embedding-ada-002` etc. first in its model list, which is why research
|
||||
# was failing with "Cannot reach model 'text-embedding-ada-002'".
|
||||
_NON_CHAT_MODEL = (
|
||||
"text-embedding", "embedding", "tts-", "whisper", "dall-e",
|
||||
"moderation", "rerank", "reranker", "clip", "stable-diffusion",
|
||||
)
|
||||
|
||||
|
||||
def _first_chat_model(models) -> str:
|
||||
"""First model that isn't an embedding/tts/etc. — falls back to models[0]."""
|
||||
for m in (models or []):
|
||||
if not any(p in str(m).lower() for p in _NON_CHAT_MODEL):
|
||||
return m
|
||||
return (models[0] if models else "")
|
||||
|
||||
|
||||
def _resolve_research_endpoint(sess, owner: Optional[str] = None) -> tuple:
|
||||
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
||||
owner = owner or 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
|
||||
|
||||
|
||||
def _owned_enabled_endpoint(db, owner, endpoint_id=None):
|
||||
"""An enabled ModelEndpoint VISIBLE to `owner` (their own rows + legacy
|
||||
null-owner "shared" rows), optionally narrowed to a specific endpoint_id;
|
||||
None if nothing visible matches.
|
||||
|
||||
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
||||
owner = private, "the model picker only shows the endpoint to that user") and
|
||||
holds a decrypted `api_key`. /api/research/start feeds the resolved row's
|
||||
api_key + base_url into research_handler.start_research(llm_endpoint=,
|
||||
llm_headers=), so an UNSCOPED lookup — by the caller-supplied endpoint_id, or
|
||||
via the bare first-enabled fallback — would let a research-privileged user
|
||||
spend ANOTHER user's API key/quota and reach whatever internal base_url they
|
||||
configured. Mirrors webhook_routes._first_enabled_endpoint and
|
||||
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
||||
legacy mode).
|
||||
"""
|
||||
from src.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if endpoint_id:
|
||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||
return owner_filter(q, ModelEndpoint, owner).first()
|
||||
|
||||
|
||||
def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
"""Resolve a ModelEndpoint row into (chat_url, model, headers).
|
||||
|
||||
Mirrors endpoint_resolver.resolve_endpoint's provider-auth handling for
|
||||
panel-selected research endpoints. ChatGPT Subscription endpoints keep
|
||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||
)
|
||||
|
||||
try:
|
||||
base, api_key = resolve_model_endpoint_runtime(ep, owner=owner)
|
||||
except Exception as e:
|
||||
logger.warning("Could not resolve endpoint credentials for research: %s", e)
|
||||
return None
|
||||
|
||||
ep_model = (model or "").strip()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
if not ep_model:
|
||||
return None
|
||||
return build_chat_url(base), ep_model, build_headers(api_key, base)
|
||||
|
||||
|
||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
router = APIRouter(tags=["research"])
|
||||
|
||||
def _require_user(request: Request) -> str:
|
||||
"""All research endpoints require an authenticated user. Research
|
||||
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
||||
block anonymous access. Multi-tenant deploys should additionally
|
||||
verify the session belongs to this user."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
if _auth_disabled():
|
||||
return ""
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
def _validate_session_id(session_id: str) -> None:
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
raise HTTPException(400, "Invalid session ID format")
|
||||
|
||||
def _owns_in_memory(session_id: str, user: str) -> bool:
|
||||
"""Ownership check for an in-flight (in-memory) research task.
|
||||
Falls back to the on-disk JSON if the task has already finished."""
|
||||
entry = research_handler._active_tasks.get(session_id)
|
||||
if entry is not None:
|
||||
return entry.get("owner", "") == user
|
||||
# Task no longer in memory — check the persisted JSON.
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")).get("owner") == user
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@router.get("/api/research/active")
|
||||
async def research_active(request: Request):
|
||||
"""List all currently active (running) research tasks."""
|
||||
user = _require_user(request)
|
||||
active = []
|
||||
for sid, entry in research_handler._active_tasks.items():
|
||||
# SECURITY: only show this user's running tasks.
|
||||
if entry.get("owner", "") != user:
|
||||
continue
|
||||
if entry.get("status") == "running":
|
||||
active.append({
|
||||
"session_id": sid,
|
||||
"query": entry.get("query", ""),
|
||||
"status": "running",
|
||||
"progress": entry.get("progress", {}),
|
||||
"started_at": entry.get("started_at", 0),
|
||||
})
|
||||
return {"active": active}
|
||||
|
||||
@router.get("/api/research/status/{session_id}")
|
||||
async def research_status(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return status
|
||||
|
||||
@router.post("/api/research/cancel/{session_id}")
|
||||
async def research_cancel(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
cancelled = research_handler.cancel_research(session_id)
|
||||
return {"cancelled": cancelled}
|
||||
|
||||
@router.post("/api/research/result/{session_id}")
|
||||
async def research_result(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research result available")
|
||||
result = research_handler.get_result(session_id)
|
||||
if result is None:
|
||||
raise HTTPException(404, "No research result available")
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
raw_findings = research_handler.get_raw_findings(session_id) or []
|
||||
research_handler.clear_result(session_id)
|
||||
return {"result": result, "sources": sources, "raw_findings": raw_findings}
|
||||
|
||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||
Use BEFORE returning any data or mutating the file."""
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
||||
except Exception:
|
||||
raise HTTPException(404, "Research not found")
|
||||
if owner != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
|
||||
@router.get("/api/research/report/{session_id}")
|
||||
async def research_report(session_id: str, request: Request):
|
||||
"""Serve the visual HTML report for a completed research session."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
_assert_owns_research(session_id, user)
|
||||
logger.info(f"Visual report requested for session {session_id}")
|
||||
try:
|
||||
html_content = research_handler.get_report_html(session_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Visual report generation error: {e}", exc_info=True)
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
if html_content is None:
|
||||
logger.warning(f"No report data found for session {session_id}")
|
||||
raise HTTPException(404, "No visual report available for this session")
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
class HideImageRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
@router.post("/api/research/{session_id}/hide-image")
|
||||
async def research_hide_image(session_id: str, body: HideImageRequest, request: Request):
|
||||
"""Mark an image URL as hidden for this research's visual report.
|
||||
Persisted to the research JSON so subsequent /report renders skip it."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
_assert_owns_research(session_id, user)
|
||||
ok = research_handler.hide_image(session_id, body.url)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/research/{session_id}/unhide-images")
|
||||
async def research_unhide_images(session_id: str, request: Request):
|
||||
"""Clear the hidden-images list for a research session."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
_assert_owns_research(session_id, user)
|
||||
ok = research_handler.unhide_all_images(session_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/api/research/library")
|
||||
async def research_library(
|
||||
request: Request,
|
||||
search: Optional[str] = Query(None),
|
||||
sort: str = Query("recent"),
|
||||
limit: int = Query(50),
|
||||
archived: bool = Query(False),
|
||||
):
|
||||
user = _require_user(request)
|
||||
"""List all completed research for the Library panel."""
|
||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||
items = []
|
||||
for p in data_dir.glob("*.json"):
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
# SECURITY: only show research belonging to this user. Legacy
|
||||
# JSONs without an `owner` field are hidden — auth was the only
|
||||
# gate before, so every user saw every other user's reports.
|
||||
if d.get("owner") != user:
|
||||
continue
|
||||
# Archived view shows ONLY archived reports; default hides them.
|
||||
if bool(d.get("archived")) != archived:
|
||||
continue
|
||||
query = d.get("query", "")
|
||||
if search and search.lower() not in query.lower():
|
||||
continue
|
||||
sources = d.get("sources", [])
|
||||
items.append({
|
||||
"id": p.stem,
|
||||
"query": query,
|
||||
"category": d.get("category") or "",
|
||||
"source_count": len(sources),
|
||||
"status": d.get("status", "done"),
|
||||
"duration": d.get("stats", {}).get("Duration", ""),
|
||||
"rounds": d.get("stats", {}).get("Rounds", ""),
|
||||
"started_at": d.get("started_at", 0),
|
||||
"completed_at": d.get("completed_at", 0),
|
||||
"archived": bool(d.get("archived")),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort
|
||||
if sort == "recent":
|
||||
items.sort(key=lambda x: x["completed_at"] or 0, reverse=True)
|
||||
elif sort == "oldest":
|
||||
items.sort(key=lambda x: x["completed_at"] or 0)
|
||||
elif sort == "most-messages":
|
||||
items.sort(key=lambda x: x["source_count"], reverse=True)
|
||||
elif sort == "alpha":
|
||||
items.sort(key=lambda x: x["query"].lower())
|
||||
|
||||
return {"research": items[:limit], "total": len(items)}
|
||||
|
||||
@router.get("/api/research/detail/{session_id}")
|
||||
async def research_detail(session_id: str, request: Request):
|
||||
"""Return the full JSON for a single research result — sources,
|
||||
summary, stats — used by the Library preview panel."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to read research: {e}")
|
||||
# SECURITY: 404 (not 403) so we don't leak that the report exists.
|
||||
if data.get("owner") != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return data
|
||||
|
||||
@router.post("/api/research/{session_id}/archive")
|
||||
async def research_archive(session_id: str, request: Request, archived: bool = Query(True)):
|
||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("owner") != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
data["archived"] = bool(archived)
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to update research: {e}")
|
||||
return {"ok": True, "id": session_id, "archived": bool(archived)}
|
||||
|
||||
@router.delete("/api/research/{session_id}")
|
||||
async def research_delete(session_id: str, request: Request):
|
||||
"""Delete a research result from disk."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||
json_path = data_dir / f"{session_id}.json"
|
||||
deleted = False
|
||||
if json_path.exists():
|
||||
# SECURITY: verify ownership before letting the caller delete it.
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if data.get("owner") != user:
|
||||
raise HTTPException(404, "Research not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(404, "Research not found")
|
||||
json_path.unlink()
|
||||
deleted = True
|
||||
return {"deleted": deleted}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Panel endpoints — launch research without a chat session
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class ResearchStartRequest(BaseModel):
|
||||
query: str
|
||||
# max_rounds=0 means "Auto" — let the AI decide when to stop, capped at 20.
|
||||
max_rounds: int = Field(default=0, ge=0, le=20)
|
||||
search_provider: Optional[str] = None
|
||||
endpoint_id: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
max_time: int = Field(default=300, ge=60, le=1800)
|
||||
extraction_timeout: Optional[int] = Field(default=None, ge=15, le=3600)
|
||||
extraction_concurrency: Optional[int] = Field(default=None, ge=1, le=12)
|
||||
category: Optional[str] = None
|
||||
|
||||
@router.post("/api/research/start")
|
||||
async def research_start(body: ResearchStartRequest, request: Request):
|
||||
"""Launch a research job from the dedicated panel."""
|
||||
from src.auth_helpers import require_privilege
|
||||
user = require_privilege(request, "can_use_research")
|
||||
if user == INTERNAL_TOOL_USER:
|
||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||
try:
|
||||
privs = auth_mgr.get_privileges(tool_owner) or {}
|
||||
if not privs.get("can_use_research", True):
|
||||
raise HTTPException(403, f"Your account is not allowed to can use research.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
user = tool_owner
|
||||
session_id = f"rp-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
if body.endpoint_id:
|
||||
from src.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Owner-scoped: never resolve another user's private endpoint
|
||||
# (and its decrypted api_key / internal base_url). A scoped miss
|
||||
# reads as 404 so the endpoint's existence isn't revealed.
|
||||
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found or disabled")
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
|
||||
if not resolved:
|
||||
raise HTTPException(400, "Endpoint is not configured with a usable model.")
|
||||
ep_url, ep_model, ep_headers = resolved
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("research", owner=user)
|
||||
if not ep_url:
|
||||
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", owner=user)
|
||||
if not ep_url:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
||||
if not ep_url:
|
||||
from src.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Owner-scoped first-enabled fallback: the caller's own rows
|
||||
# + legacy null-owner shared rows only — never borrow another
|
||||
# user's private endpoint/api_key. Same fix as the
|
||||
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
ep_url, ep_model, ep_headers = resolved
|
||||
finally:
|
||||
db.close()
|
||||
if not ep_url:
|
||||
raise HTTPException(400, "No endpoints configured. Add one in Settings first.")
|
||||
if body.model:
|
||||
ep_model = body.model
|
||||
|
||||
# max_rounds=0 → "Auto", let AI decide; pass 20 as the safety cap.
|
||||
effective_max_rounds = body.max_rounds if body.max_rounds > 0 else 20
|
||||
research_handler.start_research(
|
||||
session_id=session_id,
|
||||
query=body.query,
|
||||
llm_endpoint=ep_url,
|
||||
llm_model=ep_model,
|
||||
max_time=body.max_time,
|
||||
llm_headers=ep_headers,
|
||||
max_rounds=effective_max_rounds,
|
||||
search_provider=body.search_provider or None,
|
||||
category=body.category or None,
|
||||
extraction_timeout=body.extraction_timeout,
|
||||
extraction_concurrency=body.extraction_concurrency,
|
||||
owner=user,
|
||||
)
|
||||
return {"session_id": session_id, "status": "running", "query": body.query}
|
||||
|
||||
@router.get("/api/research/stream/{session_id}")
|
||||
async def research_stream(session_id: str, request: Request):
|
||||
"""SSE stream of research progress events."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
async def _generate():
|
||||
last_progress = None
|
||||
while True:
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
return
|
||||
st = status.get("status", "")
|
||||
progress = status.get("progress", {})
|
||||
if progress != last_progress:
|
||||
last_progress = progress
|
||||
yield f"data: {json.dumps({**progress, 'status': st})}\n\n"
|
||||
if st != "running":
|
||||
final = {'status': st, 'final': True}
|
||||
task = research_handler._active_tasks.get(session_id, {})
|
||||
if st == "error" and task.get("result"):
|
||||
final['error'] = str(task["result"])[:500]
|
||||
yield f"data: {json.dumps(final)}\n\n"
|
||||
return
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@router.post("/api/research/result-peek/{session_id}")
|
||||
async def research_result_peek(session_id: str, request: Request):
|
||||
"""Get research result without clearing it (for panel use)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
result = research_handler.get_result(session_id)
|
||||
if result is None:
|
||||
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if p.exists():
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"result": d.get("result", ""),
|
||||
"sources": d.get("sources", []),
|
||||
"raw_findings": d.get("raw_findings", []),
|
||||
"category": d.get("category") or "",
|
||||
}
|
||||
raise HTTPException(404, "No research result available")
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
raw_findings = research_handler.get_raw_findings(session_id) or []
|
||||
return {"result": result, "sources": sources, "raw_findings": raw_findings, "category": ""}
|
||||
|
||||
@router.post("/api/research/spinoff/{session_id}")
|
||||
async def research_spinoff(session_id: str, request: Request):
|
||||
"""Create a new chat session pre-seeded with this research as context.
|
||||
|
||||
Reads the persisted research result + sources for `session_id`, creates
|
||||
a fresh session (inheriting endpoint/model/headers from the source
|
||||
session if available, otherwise from the resolved chat endpoint), and
|
||||
injects a single system message containing the report and sources so
|
||||
the user can ask follow-up questions in a clean conversation.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
# SECURITY: gate on ownership before reading the persisted research —
|
||||
# otherwise any authenticated user could spin off (and thereby read)
|
||||
# another user's report by guessing its session ID. Mirrors every other
|
||||
# endpoint in this file (see result_peek above).
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
if session_manager is None:
|
||||
raise HTTPException(500, "session_manager not configured")
|
||||
|
||||
# Load research data — prefer in-memory result, fall back to disk
|
||||
result = research_handler.get_result(session_id)
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
query = ""
|
||||
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if path.exists():
|
||||
try:
|
||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not result:
|
||||
result = disk.get("result")
|
||||
if not sources:
|
||||
sources = disk.get("sources", []) or []
|
||||
query = disk.get("query", "") or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read research JSON for spinoff: {e}")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(404, "No research result available for this session")
|
||||
|
||||
# Inherit endpoint/model/headers from the source session when possible.
|
||||
# For panel-launched research (rp-* IDs), there is no chat session, so
|
||||
# fall back through the same chain as /api/research/start: research →
|
||||
# utility → first enabled endpoint in the DB.
|
||||
ep_url, ep_model, ep_headers = "", "", {}
|
||||
try:
|
||||
src_sess = session_manager.get_session(session_id)
|
||||
ep_url = src_sess.endpoint_url or ""
|
||||
ep_model = src_sess.model or ""
|
||||
ep_headers = dict(src_sess.headers or {})
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _merge(r_url, r_model, r_headers):
|
||||
nonlocal ep_url, ep_model, ep_headers
|
||||
if not ep_url and r_url:
|
||||
ep_url = r_url
|
||||
if not ep_model and r_model:
|
||||
ep_model = r_model
|
||||
if not ep_headers and r_headers:
|
||||
ep_headers = dict(r_headers)
|
||||
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("chat", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("research", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
_merge(*resolve_endpoint("utility", owner=user))
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not ep_url or not ep_model:
|
||||
raise HTTPException(400, "No endpoint configured — add one in Settings first")
|
||||
|
||||
# Create new session
|
||||
new_sid = str(uuid.uuid4())
|
||||
|
||||
title_query = (query or "research").strip()
|
||||
if len(title_query) > 60:
|
||||
title_query = title_query[:57] + "…"
|
||||
new_name = f"Follow-up: {title_query}"
|
||||
|
||||
new_sess = session_manager.create_session(
|
||||
session_id=new_sid,
|
||||
name=new_name,
|
||||
endpoint_url=ep_url,
|
||||
model=ep_model,
|
||||
rag=False,
|
||||
owner=user,
|
||||
)
|
||||
if ep_headers:
|
||||
new_sess.headers = ep_headers
|
||||
session_manager.save_sessions()
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", user)
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
|
||||
# Build the priming system message — report only, no sources injected.
|
||||
# The user can open the visual report for source details; keeping sources
|
||||
# out of the chat context saves tokens and avoids the AI fabricating
|
||||
# citations.
|
||||
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
primer = (
|
||||
f"[Research context — {date_str}]\n\n"
|
||||
f"The user previously ran a deep research investigation. Use the "
|
||||
f"report below as your primary knowledge base when answering "
|
||||
f"follow-up questions. If the user asks something not covered, "
|
||||
f"say so plainly rather than guessing.\n\n"
|
||||
f"=== ORIGINAL QUERY ===\n{query or '(not recorded)'}\n\n"
|
||||
f"=== REPORT ===\n{result}"
|
||||
)
|
||||
|
||||
from core.models import ChatMessage
|
||||
new_sess.add_message(ChatMessage(
|
||||
role="system",
|
||||
content=primer,
|
||||
metadata={"research_spinoff_from": session_id},
|
||||
))
|
||||
session_manager.save_sessions()
|
||||
|
||||
return {
|
||||
"session_id": new_sid,
|
||||
"name": new_name,
|
||||
"source_count": len(sources),
|
||||
}
|
||||
|
||||
return router
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
+21
-5
@@ -1063,8 +1063,19 @@ def setup_shell_routes() -> APIRouter:
|
||||
importlib.invalidate_caches()
|
||||
try:
|
||||
user_site = site.getusersitepackages()
|
||||
if user_site and os.path.isdir(user_site) and user_site not in sys.path:
|
||||
sys.path.append(user_site)
|
||||
if user_site and os.path.isdir(user_site):
|
||||
# Use addsitedir(), NOT a bare sys.path.append(). When a package
|
||||
# is `pip install --user`'d at runtime (Cookbook → Install) the
|
||||
# long-lived server process started before the user-site existed,
|
||||
# so site never processed it — including its `.pth` hooks. On
|
||||
# Python 3.12+ `distutils` is gone from stdlib and is only
|
||||
# restored by setuptools' `distutils-precedence.pth`, which ships
|
||||
# in user-site. basicsr (a realesrgan dep) does `import distutils`
|
||||
# at import time, so a plain append left the package importable
|
||||
# but `import distutils` failing → realesrgan probed as
|
||||
# not-installed until a full process restart. addsitedir() replays
|
||||
# the `.pth` files so the shim is active.
|
||||
site.addsitedir(user_site)
|
||||
except Exception:
|
||||
pass
|
||||
if ssh_port and str(ssh_port).strip() not in ("", "22"):
|
||||
@@ -1377,11 +1388,16 @@ def setup_shell_routes() -> APIRouter:
|
||||
pkg["installed"] = False
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
pkg["installed"] = False
|
||||
except Exception:
|
||||
except (Exception, SystemExit):
|
||||
# Installed but crashes on import — e.g. a CUDA build of
|
||||
# llama-cpp-python raising FileNotFoundError when the CUDA
|
||||
# toolkit dir is absent. One broken optional package must not
|
||||
# 500 the entire packages panel; report it as not usable.
|
||||
# toolkit dir is absent, or rembg calling sys.exit(1) when no
|
||||
# onnxruntime backend can be loaded. SystemExit is a
|
||||
# BaseException, not Exception, so without catching it here a
|
||||
# single sys.exit-on-import package escapes and takes down the
|
||||
# whole packages panel / worker (the panel hangs forever). One
|
||||
# broken optional package must not 500 — or hang — the entire
|
||||
# panel; report it as not usable.
|
||||
pkg["installed"] = False
|
||||
|
||||
# llama_cpp partial-state probe: when the package is installed
|
||||
|
||||
+11
-1
@@ -22,6 +22,16 @@ from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Last-resort verdict extraction from a teacher/verifier model's prose (run when
|
||||
# JSON parsing fails). `["\'\s:]*` already consumes whitespace, so the original
|
||||
# trailing `\s*` made two adjacent \s-matching quantifiers that backtrack O(n^2)
|
||||
# on a `verdict` + whitespace flood in untrusted model output (CodeQL
|
||||
# py/polynomial-redos). Without it a single unbounded quantifier remains — the
|
||||
# matched text is identical, and the scan is linear.
|
||||
_VERDICT_PROSE_RE = re.compile(
|
||||
r'verdict["\'\s:]*["\']?(pass|needs_work|fail|inconclusive)', re.I
|
||||
)
|
||||
|
||||
|
||||
class SkillAddRequest(BaseModel):
|
||||
# New schema (preferred)
|
||||
@@ -196,7 +206,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
# Last resort: pull the verdict keyword straight out of the prose so a
|
||||
# clearly-decided run isn't thrown away as "unparseable".
|
||||
if v not in _VERDICTS:
|
||||
km = _re.search(r'verdict["\'\s:]*\s*["\']?(pass|needs_work|fail|inconclusive)', text, _re.I)
|
||||
km = _VERDICT_PROSE_RE.search(text)
|
||||
if km:
|
||||
v = km.group(1).lower()
|
||||
if data is None:
|
||||
|
||||
+15
-16
@@ -201,14 +201,13 @@ def setup_upload_routes(upload_handler):
|
||||
import mimetypes as _mt
|
||||
# Look up original filename and owner from uploads.json
|
||||
original_name = file_id
|
||||
info = None
|
||||
uploads_db = os.path.join(_upload_root(), "uploads.json")
|
||||
if os.path.exists(uploads_db):
|
||||
with open(uploads_db, encoding="utf-8") as f:
|
||||
db = json.load(f)
|
||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
if info:
|
||||
original_name = info.get("name", file_id)
|
||||
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||
db = upload_handler._load_upload_index()
|
||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
if info:
|
||||
original_name = info.get("name", file_id)
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||
current_user = effective_user(request)
|
||||
@@ -254,13 +253,10 @@ def setup_upload_routes(upload_handler):
|
||||
|
||||
def _load_upload_info(file_id: str):
|
||||
"""Look up the uploads.json record for a file_id, with owner/auth checks."""
|
||||
info = None
|
||||
uploads_db = os.path.join(_upload_root(), "uploads.json")
|
||||
if os.path.exists(uploads_db):
|
||||
with open(uploads_db, encoding="utf-8") as f:
|
||||
db = json.load(f)
|
||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
return info
|
||||
# Corruption-tolerant load (see download_file): a bad uploads.json yields
|
||||
# {} rather than raising JSONDecodeError out of the vision path.
|
||||
db = upload_handler._load_upload_index()
|
||||
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
|
||||
def _vision_cache_path(file_id: str) -> str:
|
||||
cache_dir = os.path.join(_upload_root(), ".vision")
|
||||
@@ -357,7 +353,10 @@ def setup_upload_routes(upload_handler):
|
||||
if file_owner != current_user and not auth_mgr.is_admin(current_user):
|
||||
raise HTTPException(404, "File not found")
|
||||
_resolve_upload_path(file_id)
|
||||
body = await request.json()
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Request body must be valid JSON")
|
||||
text = (body or {}).get("text", "")
|
||||
if not isinstance(text, str):
|
||||
raise HTTPException(400, "text must be a string")
|
||||
|
||||
@@ -345,8 +345,9 @@ def setup_webhook_routes(
|
||||
resp = await client.get(models_url, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not ids:
|
||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
if not ids and isinstance(data, dict):
|
||||
ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
|
||||
Reference in New Issue
Block a user