mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 893e490cdc | |||
| bad9ec2f9c | |||
| 927b1f7ecf | |||
| bb2148db73 | |||
| e018c7cf6c |
@@ -621,7 +621,7 @@ app.include_router(setup_chat_routes(
|
||||
))
|
||||
|
||||
# Research (background deep-research tasks)
|
||||
from routes.research_routes import setup_research_routes
|
||||
from routes.research.research_routes import setup_research_routes
|
||||
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
||||
|
||||
# History
|
||||
|
||||
@@ -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 (";", "&&", "||", "$(")):
|
||||
|
||||
@@ -77,39 +77,6 @@ def _normalize_image_endpoint_base(url: str) -> str:
|
||||
return base
|
||||
|
||||
|
||||
def _is_openai_api_base(url: str) -> bool:
|
||||
"""Return True only when url's hostname is exactly api.openai.com."""
|
||||
from urllib.parse import urlsplit
|
||||
try:
|
||||
candidate = url if "://" in url else f"https://{url}"
|
||||
return urlsplit(candidate).hostname == "api.openai.com"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
"/images/harmonize",
|
||||
"/images/img2img",
|
||||
"/images/inpaint",
|
||||
"/images/upscale",
|
||||
"/images/variations",
|
||||
"/sdapi/v1/img2img",
|
||||
})
|
||||
|
||||
|
||||
def _join_checked_gallery_endpoint(base: str, path: str) -> str:
|
||||
"""Append a known-constant gallery path suffix to a validated base URL.
|
||||
|
||||
Rejects paths not in the pre-approved list so arbitrary strings can never
|
||||
be spliced into the URL passed to httpx.
|
||||
"""
|
||||
if path not in _GALLERY_ENDPOINT_PATHS:
|
||||
raise ValueError(f"Unexpected gallery path: {path!r}")
|
||||
return base + path
|
||||
|
||||
|
||||
def _visible_image_endpoint_query(db, owner: str | None):
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
@@ -288,10 +255,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
pass
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("gallery_replace: DB commit failed")
|
||||
raise HTTPException(500, "Image update failed")
|
||||
raise HTTPException(500, f"DB commit failed: {e}")
|
||||
return {"ok": True, "width": img.width, "height": img.height}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -419,9 +385,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"image": data.get("data", [{}])[0].get("b64_json", "")}
|
||||
# Fallback: no upscale endpoint — return error
|
||||
return {"error": f"Upscale endpoint not available ({resp.status_code})"}
|
||||
except Exception:
|
||||
logger.exception("ai_upscale: request failed")
|
||||
return {"error": "Upscale request failed"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ---- POST /api/gallery/style-transfer ----
|
||||
@router.post("/api/gallery/style-transfer")
|
||||
@@ -466,9 +431,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if img_data:
|
||||
return {"image": img_data}
|
||||
return {"error": f"Style transfer failed ({resp.status_code})"}
|
||||
except Exception:
|
||||
logger.exception("style_transfer: request failed")
|
||||
return {"error": "Style transfer failed"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ---- GET /api/gallery/tags ----
|
||||
@router.get("/api/gallery/tags")
|
||||
@@ -624,9 +588,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
"tags": sorted(all_tags),
|
||||
"models": all_models,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch gallery library")
|
||||
raise HTTPException(500, "Failed to fetch gallery library")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch gallery library: {e}")
|
||||
raise HTTPException(500, f"Failed to fetch gallery library: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -802,10 +766,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return _image_to_dict(img)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("patch_gallery_image: update failed")
|
||||
raise HTTPException(500, "Image update failed")
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -882,10 +845,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
cleared += 1
|
||||
db.commit()
|
||||
return {"ok": True, "cleared": cleared}
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("clear_gallery_user_tags: failed")
|
||||
raise HTTPException(500, "Tag update failed")
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -909,10 +871,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
cleared += 1
|
||||
db.commit()
|
||||
return {"ok": True, "cleared": cleared}
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("clear_gallery_ai_tags: failed")
|
||||
raise HTTPException(500, "Tag update failed")
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -948,10 +909,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
img.tags = ', '.join(cleaned)
|
||||
db.commit()
|
||||
return {"ok": True, "rows_touched": rows_touched, "tags_removed": tags_removed}
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("dedupe_gallery_tags: failed")
|
||||
raise HTTPException(500, "Tag deduplication failed")
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1069,10 +1029,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"status": "deleted", "id": image_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("delete_gallery_image: failed")
|
||||
raise HTTPException(500, "Image deletion failed")
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1085,22 +1044,21 @@ def setup_gallery_routes() -> APIRouter:
|
||||
import httpx
|
||||
user = require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
# Use endpoint from request body (editor dropdown) or fall back to DB lookup.
|
||||
# Store as requested_base to avoid carrying user input into the outbound request.
|
||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||
# Use endpoint from request body (editor dropdown) or fall back to DB lookup
|
||||
base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||
# SSRF hardening: validate a client-supplied endpoint before any
|
||||
# outbound request (mirrors routes/embedding_routes.py).
|
||||
if requested_base:
|
||||
if base:
|
||||
from src.url_safety import check_outbound_url
|
||||
ok, reason = check_outbound_url(
|
||||
requested_base,
|
||||
base,
|
||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
chosen_model = (body.pop("_model", "") or "").strip()
|
||||
api_key = None
|
||||
if not requested_base:
|
||||
if not base:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _first_visible_image_endpoint(db, user)
|
||||
@@ -1111,23 +1069,32 @@ def setup_gallery_routes() -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
# Resolve the client-supplied base to a registered visible endpoint.
|
||||
# Admins are not exempted — gallery proxy routes must use a DB row
|
||||
# so the outbound URL never depends directly on request-body input.
|
||||
# Pull api_key from the matching DB row so OpenAI auth works.
|
||||
# Users may have stored base_url with/without /v1 suffix and with/without
|
||||
# trailing slash, so compare normalized forms.
|
||||
def _norm_url(u: str) -> str:
|
||||
if not u:
|
||||
return u
|
||||
u = u.rstrip("/")
|
||||
if u.endswith("/v1"):
|
||||
u = u[:-3]
|
||||
return u
|
||||
_target = _norm_url(base)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||
if not ep:
|
||||
ep = _visible_image_endpoint_for_base(db, _target, user)
|
||||
if ep:
|
||||
base = (ep.base_url or base).rstrip("/")
|
||||
api_key = ep.api_key
|
||||
elif user and not _current_user_is_admin(request, user):
|
||||
raise HTTPException(403, "Choose a registered image endpoint")
|
||||
base = ep.base_url.rstrip("/")
|
||||
api_key = ep.api_key
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not base.endswith("/v1"):
|
||||
base += "/v1"
|
||||
|
||||
is_openai = _is_openai_api_base(base)
|
||||
is_openai = "api.openai.com" in base
|
||||
|
||||
if is_openai:
|
||||
# OpenAI path: /v1/images/edits with gpt-image-1.
|
||||
@@ -1164,9 +1131,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
mask_buf.seek(0)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("inpaint_proxy: failed to prepare OpenAI request")
|
||||
raise HTTPException(400, "Failed to prepare inpaint request")
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to prepare OpenAI request: {e}")
|
||||
|
||||
width = int(body.get("width") or 1024)
|
||||
height = int(body.get("height") or 1024)
|
||||
@@ -1197,10 +1163,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
r = await client.post(f"{base}/images/edits", headers=headers, data=data, files=files)
|
||||
if r.status_code != 200:
|
||||
logger.error("inpaint_proxy OpenAI edit: status %s", r.status_code)
|
||||
raise HTTPException(r.status_code, "OpenAI edit failed")
|
||||
raise HTTPException(r.status_code, f"OpenAI edit failed: {r.text[:300]}")
|
||||
result = r.json()
|
||||
raw_b64 = None
|
||||
if result.get("data"):
|
||||
@@ -1247,18 +1212,16 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if chosen_model:
|
||||
body["model"] = chosen_model
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
|
||||
r = await client.post(f"{base}/images/inpaint", json=body)
|
||||
if r.status_code != 200:
|
||||
logger.error("inpaint_proxy diffusion: status %s", r.status_code)
|
||||
raise HTTPException(r.status_code, "Inpaint request failed")
|
||||
raise HTTPException(r.status_code, f"Inpaint failed: {r.text[:200]}")
|
||||
return r.json()
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(504, "Inpaint request timed out (120s)")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("inpaint_proxy: request failed")
|
||||
raise HTTPException(502, "Inpaint request failed")
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Inpaint error: {str(e)}")
|
||||
|
||||
# ---- POST /api/image/harmonize — proper img2img call ----
|
||||
# Earlier version routed through inpaint with a full-white mask, but
|
||||
@@ -1280,23 +1243,24 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not image_b64:
|
||||
raise HTTPException(400, "No image provided")
|
||||
|
||||
requested_base = (body.get("_endpoint") or "").rstrip("/")
|
||||
endpoint = (body.get("_endpoint") or "").rstrip("/")
|
||||
# SSRF hardening: a client-supplied endpoint is fetched server-side
|
||||
# below, so validate it first (mirrors routes/embedding_routes.py).
|
||||
# Local-first means loopback/LAN is allowed by default; the cloud
|
||||
# metadata range and non-HTTP(S) schemes are always rejected.
|
||||
if requested_base:
|
||||
if endpoint:
|
||||
from src.url_safety import check_outbound_url
|
||||
ok, reason = check_outbound_url(
|
||||
requested_base,
|
||||
endpoint,
|
||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
model = (body.get("_model") or "").strip()
|
||||
|
||||
base = endpoint
|
||||
api_key = None
|
||||
if not requested_base:
|
||||
if not base:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _first_visible_image_endpoint(db, user)
|
||||
@@ -1307,16 +1271,14 @@ def setup_gallery_routes() -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
# Resolve the client-supplied base to a registered visible endpoint.
|
||||
# Admins are not exempted — gallery proxy routes must use a DB row
|
||||
# so the outbound URL never depends directly on request-body input.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||
if not ep:
|
||||
ep = _visible_image_endpoint_for_base(db, base, user)
|
||||
if ep:
|
||||
base = (ep.base_url or base).rstrip("/")
|
||||
api_key = ep.api_key
|
||||
elif user and not _current_user_is_admin(request, user):
|
||||
raise HTTPException(403, "Choose a registered image endpoint")
|
||||
base = ep.base_url.rstrip("/")
|
||||
api_key = ep.api_key
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1351,7 +1313,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# source. Earlier hack (alpha-blend the regen back at `strength`)
|
||||
# produced visibly broken results, so we refuse and tell the
|
||||
# user to spin up a real diffusion endpoint instead.
|
||||
if _is_openai_api_base(base):
|
||||
if "api.openai.com" in base:
|
||||
raise HTTPException(400,
|
||||
"Harmonize needs a diffusion server that supports img2img "
|
||||
"(SD WebUI / Forge / Comfy). OpenAI's API doesn't expose "
|
||||
@@ -1416,16 +1378,14 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# 1024×1024 inference pass on slower setups.
|
||||
async with httpx.AsyncClient(timeout=240) as client:
|
||||
for path, kind, payload in candidates:
|
||||
_effective_base = base_root if path.startswith("/sdapi") else base
|
||||
target = _join_checked_gallery_endpoint(_effective_base, path)
|
||||
target = base_root + path if path.startswith("/sdapi") else base + path
|
||||
try:
|
||||
r = await client.post(target, json=payload, headers=headers)
|
||||
if r.status_code == 404:
|
||||
last_err = f"{path}: 404"
|
||||
continue # try next variant
|
||||
if r.status_code != 200:
|
||||
logger.warning("harmonize: %s returned %s", path, r.status_code)
|
||||
last_err = f"{path}: {r.status_code}"
|
||||
last_err = f"{path}: {r.status_code} {r.text[:120]}"
|
||||
continue
|
||||
data = r.json()
|
||||
# Normalise return shape.
|
||||
@@ -1434,8 +1394,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# surface it now instead of trying the other routes
|
||||
# (otherwise the real error gets buried under 404s).
|
||||
if data.get("error") and not data.get("image"):
|
||||
logger.warning("harmonize: server error at %s: %s", path, data.get("error"))
|
||||
raise HTTPException(502, f"Diffusion server error at {path}")
|
||||
raise HTTPException(502,
|
||||
f"Diffusion server error at {path}: {data['error']}")
|
||||
if data.get("image"):
|
||||
return {"image": data["image"]}
|
||||
if data.get("images") and isinstance(data["images"], list):
|
||||
@@ -1455,15 +1415,15 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if img_b64:
|
||||
return {"image": img_b64}
|
||||
last_err = f"{path}: server returned no image"
|
||||
except httpx.ConnectError:
|
||||
logger.warning("harmonize: can't reach diffusion server at %s", base)
|
||||
raise HTTPException(502, "Can't reach diffusion server")
|
||||
except httpx.ConnectError as e:
|
||||
raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}")
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(504, "Harmonize timed out (240s) — restart the diffusion server or lower Color match / disable Seam fix")
|
||||
raise HTTPException(502,
|
||||
"No supported img2img route responded. "
|
||||
"Your diffusion server needs to expose one of: "
|
||||
"/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.")
|
||||
f"None of the img2img routes worked on {base}. "
|
||||
f"Last response: {last_err or 'unknown'}. "
|
||||
"Your diffusion server needs to expose one of /v1/images/harmonize, "
|
||||
"/v1/images/img2img, /v1/images/variations, or /sdapi/v1/img2img.")
|
||||
|
||||
# ---- POST /api/image/sharpen ----
|
||||
@router.post("/api/image/sharpen")
|
||||
@@ -1507,8 +1467,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
import base64, io
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
except ImportError as e:
|
||||
raise HTTPException(500, f"Server missing dependency: {e}")
|
||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
@@ -1535,9 +1495,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
buf = io.BytesIO()
|
||||
out_img.save(buf, format="PNG")
|
||||
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
||||
except Exception:
|
||||
logger.warning("Denoise failed", exc_info=True)
|
||||
return {"error": "Denoise failed"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Denoise failed: {e}")
|
||||
return {"error": f"Denoise failed: {e}"}
|
||||
|
||||
# ---- POST /api/image/upscale-local ----
|
||||
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
|
||||
@@ -1558,8 +1518,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
import base64, io
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
except ImportError as e:
|
||||
raise HTTPException(500, f"Server missing dependency: {e}")
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
try:
|
||||
@@ -1583,9 +1543,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
buf = io.BytesIO()
|
||||
out_img.save(buf, format="PNG")
|
||||
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
||||
except Exception:
|
||||
logger.warning("AI upscale failed", exc_info=True)
|
||||
return {"error": "AI upscale failed"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Upscale failed: {e}")
|
||||
return {"error": f"Upscale failed: {e}"}
|
||||
|
||||
# ---- POST /api/image/remove-bg ----
|
||||
@router.post("/api/image/remove-bg")
|
||||
@@ -1743,9 +1703,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
buf = io.BytesIO()
|
||||
enhanced.save(buf, format="PNG")
|
||||
return {"image": base64.b64encode(buf.getvalue()).decode(), "method": "pil"}
|
||||
except Exception:
|
||||
logger.exception("enhance_face: failed")
|
||||
raise HTTPException(500, "Face enhancement failed")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Face enhancement failed: {str(e)}")
|
||||
|
||||
# ---- Album management (path-param routes) ----
|
||||
|
||||
@@ -1940,8 +1899,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(chat_url, json=payload, headers=h)
|
||||
if resp.status_code != 200:
|
||||
logger.error("ai_tag vision model: status %s: %s", resp.status_code, resp.text[:500])
|
||||
return {"error": "Vision model request failed"}
|
||||
body = resp.text[:500]
|
||||
logger.error(f"Vision model {resp.status_code}: {body}")
|
||||
return {"error": f"Vision model returned {resp.status_code}: {body[:200]}"}
|
||||
data = resp.json()
|
||||
# Anthropic returns content[0].text, OpenAI returns choices[0].message.content
|
||||
if provider == "anthropic":
|
||||
@@ -1957,9 +1917,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"ok": True, "ai_tags": tag_str}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("AI tagging failed")
|
||||
return {"error": "Auto-tagging failed"}
|
||||
except Exception as e:
|
||||
logger.error(f"AI tagging failed: {e}")
|
||||
return {"error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-3
@@ -345,6 +345,18 @@ def _normalize_ollama_url(url: str) -> str:
|
||||
return base.rstrip("/") + "/chat"
|
||||
|
||||
|
||||
def _normalize_openai_chat_url(url: str) -> str:
|
||||
"""Ensure an OpenAI-compatible base URL points at /chat/completions."""
|
||||
base = (url or "").strip().rstrip("/")
|
||||
if not base:
|
||||
return base
|
||||
if base.endswith("/chat/completions") or base.endswith("/completions"):
|
||||
return base
|
||||
if base.endswith("/models"):
|
||||
base = base[: -len("/models")].rstrip("/")
|
||||
return base + "/chat/completions"
|
||||
|
||||
|
||||
def _ollama_normalize_messages(messages: List[Dict]) -> List[Dict]:
|
||||
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
|
||||
|
||||
@@ -1563,7 +1575,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
stream=False, num_ctx=get_context_length(url, model),
|
||||
)
|
||||
else:
|
||||
target_url = url
|
||||
target_url = _normalize_openai_chat_url(url)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
@@ -1767,7 +1779,7 @@ async def llm_call_async(
|
||||
stream=False, num_ctx=get_context_length(url, model),
|
||||
)
|
||||
else:
|
||||
target_url = url
|
||||
target_url = _normalize_openai_chat_url(url)
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
@@ -1889,7 +1901,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
h = _provider_headers(provider, headers)
|
||||
payload = _build_chatgpt_responses_payload(model, messages_copy, temperature, max_tokens, stream=True)
|
||||
else:
|
||||
target_url = url
|
||||
target_url = _normalize_openai_chat_url(url)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages_copy,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Shared imports for calendar route tests."""
|
||||
|
||||
|
||||
def import_calendar_routes():
|
||||
"""Import the calendar routes module after test stubs are installed."""
|
||||
import routes.calendar_routes as cal
|
||||
|
||||
return cal
|
||||
@@ -54,7 +54,7 @@ def test_scheduler_fallbacks_and_research_headers_are_owner_scoped():
|
||||
|
||||
|
||||
def test_research_routes_fallbacks_are_owner_scoped():
|
||||
src = _src("routes/research_routes.py")
|
||||
src = _src("routes/research/research_routes.py")
|
||||
|
||||
assert 'resolve_endpoint("research", owner=user)' in src
|
||||
assert 'resolve_endpoint("utility", owner=user)' in src
|
||||
|
||||
@@ -13,7 +13,7 @@ The fallback now normalizes to UTC and strips tz, exactly like the ISO path.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests.test_null_owner_gates import _import_calendar_helpers
|
||||
from tests.helpers.calendar_routes import import_calendar_routes
|
||||
|
||||
# Inputs datetime.fromisoformat() rejects (so they hit the dateutil fallback)
|
||||
# but that carry a numeric UTC offset dateutil resolves to tz-aware.
|
||||
@@ -25,7 +25,7 @@ _OFFSET_NONISO = [
|
||||
|
||||
@pytest.mark.parametrize("s", _OFFSET_NONISO)
|
||||
def test_parse_dt_dateutil_fallback_returns_naive(s):
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
d = cal._parse_dt(s)
|
||||
assert d.tzinfo is None, f"{s!r} leaked tz-aware: {d!r}"
|
||||
# +0900 14:00 -> 05:00 UTC, naive.
|
||||
@@ -34,13 +34,13 @@ def test_parse_dt_dateutil_fallback_returns_naive(s):
|
||||
|
||||
@pytest.mark.parametrize("s", _OFFSET_NONISO)
|
||||
def test_parse_dt_pair_fallback_returns_naive(s):
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
dt, _is_utc = cal._parse_dt_pair(s)
|
||||
assert dt.tzinfo is None, f"{s!r} leaked tz-aware via _parse_dt_pair: {dt!r}"
|
||||
|
||||
|
||||
def test_parse_dt_naive_input_unchanged():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
d = cal._parse_dt("January 5, 2026 14:00") # no offset -> stays as parsed
|
||||
assert d.tzinfo is None
|
||||
assert (d.hour, d.minute) == (14, 0)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Regression tests for calendar recurrence expansion.
|
||||
|
||||
Tests _expand_rrule and _resolve_base_uid — imported directly from
|
||||
routes/calendar_routes using the same stub-friendly import pattern
|
||||
as test_null_owner_gates.py. No live DB or FastAPI test client needed.
|
||||
routes/calendar_routes using the shared stub-friendly test helper.
|
||||
No live DB or FastAPI test client needed.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -10,34 +10,34 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_null_owner_gates import _import_calendar_helpers
|
||||
from tests.helpers.calendar_routes import import_calendar_routes
|
||||
|
||||
|
||||
# ── _resolve_base_uid ──────────────────────────────────────────────────
|
||||
|
||||
def test_resolve_base_uid_plain_passthrough():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
assert cal._resolve_base_uid("evt-123") == "evt-123"
|
||||
|
||||
|
||||
def test_resolve_base_uid_compound_strips_suffix_date():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
assert cal._resolve_base_uid("evt-123::2026-06-15") == "evt-123"
|
||||
|
||||
|
||||
def test_resolve_base_uid_compound_strips_suffix_datetime():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
assert cal._resolve_base_uid("evt-123::2026-06-15T09:00") == "evt-123"
|
||||
|
||||
|
||||
def test_resolve_base_uid_rejects_empty():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
with pytest.raises(ValueError, match="empty uid"):
|
||||
cal._resolve_base_uid("")
|
||||
|
||||
|
||||
def test_resolve_base_uid_rejects_missing_base():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
with pytest.raises(ValueError, match="malformed compound UID"):
|
||||
cal._resolve_base_uid("::2026-06-15")
|
||||
|
||||
@@ -73,7 +73,7 @@ def _make_event(**overrides):
|
||||
|
||||
def test_expand_non_recurring_returns_single():
|
||||
"""Non-recurring events pass through unchanged with series_uid=uid."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(rrule="")
|
||||
results = cal._expand_rrule(ev, datetime(2026, 5, 1), datetime(2026, 7, 1))
|
||||
|
||||
@@ -90,7 +90,7 @@ def test_expand_yearly_old_dtstart_later_year_single_occurrence():
|
||||
|
||||
This is the explicit regression case from PR review feedback.
|
||||
"""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-bday-001",
|
||||
summary="Annual Review",
|
||||
@@ -118,7 +118,7 @@ def test_expand_yearly_narrow_window_after_dtstart_returns_one():
|
||||
"""DTSTART=2020, query just two months in 2029 — should return
|
||||
exactly one occurrence (the one that falls in that window).
|
||||
"""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-ann",
|
||||
dtstart=datetime(2020, 3, 1),
|
||||
@@ -137,7 +137,7 @@ def test_expand_yearly_strict_before_window_returns_empty():
|
||||
"""DTSTART=2020, query a window that ends before the yearly
|
||||
occurrence in that year. Should return zero.
|
||||
"""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-late",
|
||||
dtstart=datetime(2020, 12, 25),
|
||||
@@ -154,7 +154,7 @@ def test_expand_yearly_strict_after_window_returns_empty():
|
||||
"""DTSTART=2020. Query a window that starts after the occurrence in
|
||||
that year. Should return zero.
|
||||
"""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-early",
|
||||
dtstart=datetime(2020, 1, 15),
|
||||
@@ -171,7 +171,7 @@ def test_expand_weekly_unique_no_overwrites():
|
||||
"""Multiple occurrences from the same series must have unique UIDs
|
||||
so _allEvents[uid] = ev doesn't overwrite earlier ones.
|
||||
"""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-wk",
|
||||
dtstart=datetime(2026, 6, 1, 9, 0),
|
||||
@@ -192,7 +192,7 @@ def test_expand_weekly_unique_no_overwrites():
|
||||
|
||||
|
||||
def test_expand_monthly_all_day():
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-rent",
|
||||
dtstart=datetime(2026, 1, 1),
|
||||
@@ -210,7 +210,7 @@ def test_expand_monthly_all_day():
|
||||
def test_expand_bad_rrule_graceful():
|
||||
"""Malformed rrule should fall back to returning the base event,
|
||||
but only when the base event overlaps the requested window."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-broken",
|
||||
rrule="FREQ=GARBAGE",
|
||||
@@ -225,7 +225,7 @@ def test_expand_bad_rrule_graceful():
|
||||
def test_expand_bad_rrule_fallback_rejects_non_overlapping():
|
||||
"""Malformed rrule with a base event outside the requested window
|
||||
must return zero results, not leak the event into an unrelated range."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-old-broken",
|
||||
dtstart=datetime(2020, 1, 1, 9, 0),
|
||||
@@ -243,7 +243,7 @@ def test_expand_bad_rrule_fallback_rejects_non_overlapping():
|
||||
def test_expand_exclusive_end_boundary():
|
||||
"""An occurrence whose start equals the window end must be excluded.
|
||||
The contract is [start, end), same as the non-recurring SQL filter."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-daily",
|
||||
dtstart=datetime(2026, 6, 1, 9, 0),
|
||||
@@ -260,7 +260,7 @@ def test_expand_exclusive_end_boundary():
|
||||
def test_expand_multi_day_crossing_range_start():
|
||||
"""A multi-day occurrence that starts before the window but ends inside
|
||||
it must be included (matching non-recurring overlap: dtend > start)."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-weekly-multi",
|
||||
summary="Weekend Trip",
|
||||
@@ -285,7 +285,7 @@ def test_expand_multi_day_crossing_range_start():
|
||||
def test_expand_multi_day_fully_before_window():
|
||||
"""A multi-day occurrence that ends exactly at the window start
|
||||
must be excluded (occ_end <= start)."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-multi",
|
||||
dtstart=datetime(2026, 5, 29, 18, 0),
|
||||
@@ -301,7 +301,7 @@ def test_expand_multi_day_fully_before_window():
|
||||
def test_expand_metadata_inheritance():
|
||||
"""Occurrence dicts must carry the base event's metadata
|
||||
(summary, importance, event_type, color, location)."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-meta",
|
||||
summary="Board Meeting",
|
||||
@@ -323,7 +323,7 @@ def test_expand_metadata_inheritance():
|
||||
|
||||
def test_expand_daily_rrule_large_window_is_capped_and_marked_truncated():
|
||||
"""Wide recurring windows must not materialize unbounded occurrence lists."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(
|
||||
uid="evt-daily-cap",
|
||||
dtstart=datetime(2020, 1, 1, 9, 0),
|
||||
|
||||
@@ -24,7 +24,7 @@ UNTIL must expand to all of its occurrences.
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.test_null_owner_gates import _import_calendar_helpers
|
||||
from tests.helpers.calendar_routes import import_calendar_routes
|
||||
|
||||
|
||||
_MOCK_CAL = SimpleNamespace(name="Personal", color="#5b8abf")
|
||||
@@ -55,7 +55,7 @@ def _make_event(**overrides):
|
||||
def test_expand_rrule_with_utc_until_keeps_all_occurrences():
|
||||
"""FREQ=DAILY;UNTIL=...Z must expand to every occurrence, not collapse
|
||||
to a single non-recurring event."""
|
||||
cal = _import_calendar_helpers()
|
||||
cal = import_calendar_routes()
|
||||
ev = _make_event(rrule="FREQ=DAILY;UNTIL=20240105T090000Z")
|
||||
|
||||
results = cal._expand_rrule(ev, datetime(2024, 1, 1), datetime(2024, 1, 10))
|
||||
|
||||
@@ -917,3 +917,42 @@ def test_cached_model_scan_runs_additional_hf_cache(tmp_path):
|
||||
assert rec["size_bytes"] == len(b"abc123")
|
||||
assert rec["has_incomplete"] is False
|
||||
assert rec["is_diffusion"] is False
|
||||
|
||||
|
||||
def test_validate_serve_cmd_accepts_find_subshell_for_mmproj():
|
||||
"""$(find …) for mmproj path should be accepted, same as $(printf %s …)."""
|
||||
cmd = (
|
||||
"HIP_VISIBLE_DEVICES=0 llama-server "
|
||||
"--model \"$(printf %s '/app/.cache/huggingface/hub/models--unsloth--gemma-4-E2B-it-GGUF"
|
||||
"/snapshots/90f9618340396838ee7ff5b0ba2da27da62953d3/gemma-4-E2B-it-Q4_K_M.gguf')\" "
|
||||
"--host 0.0.0.0 --port 8000 -ngl 99 -c 131072 "
|
||||
"--flash-attn on --cache-type-k q8_0 --cache-type-v q8_0 "
|
||||
"--mmproj \"$(find '/app/.cache/huggingface/hub/models--unsloth--gemma-4-E2B-it-GGUF"
|
||||
"/snapshots' -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)\" "
|
||||
"--image-max-tokens 1024"
|
||||
)
|
||||
assert _validate_serve_cmd(cmd) == cmd
|
||||
|
||||
|
||||
def test_validate_serve_cmd_rejects_unrelated_subshells():
|
||||
for cmd in [
|
||||
"llama-server --model \"$(curl https://example.invalid/model.gguf)\" --host 0.0.0.0 --port 8000",
|
||||
"llama-server --model \"$(rm -rf /tmp/not-a-model)\" --host 0.0.0.0 --port 8000",
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_serve_cmd(cmd)
|
||||
|
||||
|
||||
def test_validate_serve_cmd_rejects_unrelated_subshell_pipelines():
|
||||
for cmd in [
|
||||
(
|
||||
"llama-server --model model.gguf "
|
||||
"--mmproj \"$(find '/app/models' -iname 'mmproj*.gguf' | xargs head -1)\""
|
||||
),
|
||||
(
|
||||
"llama-server --model model.gguf "
|
||||
"--mmproj \"$(find '/app/models' -iname '*.gguf' 2>/dev/null | sort | head -1)\""
|
||||
),
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_serve_cmd(cmd)
|
||||
|
||||
@@ -402,7 +402,7 @@ def test_gallery_image_endpoint_lookups_are_owner_scoped():
|
||||
|
||||
|
||||
def test_research_endpoint_resolution_passes_owner():
|
||||
body = Path("routes/research_routes.py").read_text(encoding="utf-8")
|
||||
body = Path("routes/research/research_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "def _resolve_research_endpoint(sess, owner:" in body
|
||||
assert 'resolve_endpoint("research", owner=user)' in body
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
"""Focused security tests for gallery endpoint URL hardening.
|
||||
|
||||
Covers:
|
||||
- _is_openai_api_base: exact hostname matching (no substring bypass)
|
||||
- _join_checked_gallery_endpoint: allowlist-only path construction
|
||||
- No bare str(e) / f"...{e}" in gallery exception handlers
|
||||
- harmonize validates _endpoint via check_outbound_url
|
||||
- Target URL construction only appends constant paths to the validated base
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "routes" / "gallery" / "gallery_routes.py"
|
||||
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_openai_api_base — exact hostname, no substring tricks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_openai_api_base_accepts_exact_host():
|
||||
f = gallery_routes._is_openai_api_base
|
||||
assert f("https://api.openai.com") is True
|
||||
assert f("https://api.openai.com/v1") is True
|
||||
assert f("https://api.openai.com/") is True
|
||||
assert f("api.openai.com") is True
|
||||
|
||||
|
||||
def test_is_openai_api_base_rejects_path_embed():
|
||||
# attacker hides api.openai.com in the path, not the hostname
|
||||
f = gallery_routes._is_openai_api_base
|
||||
assert f("https://evil.test/api.openai.com/v1") is False
|
||||
|
||||
|
||||
def test_is_openai_api_base_rejects_subdomain_suffix():
|
||||
# hostname ends with .openai.com but isn't exactly api.openai.com
|
||||
f = gallery_routes._is_openai_api_base
|
||||
assert f("https://api.openai.com.evil.test/v1") is False
|
||||
assert f("https://evil-api.openai.com/v1") is False
|
||||
assert f("https://notapi.openai.com/v1") is False
|
||||
|
||||
|
||||
def test_is_openai_api_base_rejects_malformed():
|
||||
f = gallery_routes._is_openai_api_base
|
||||
assert f("") is False
|
||||
assert f("not a url at all !!!") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-level: gallery no longer uses substring "api.openai.com" in base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gallery_does_not_use_openai_substring_check():
|
||||
src = SRC.read_text()
|
||||
assert '"api.openai.com" in base' not in src, (
|
||||
"Substring OpenAI check still present — use _is_openai_api_base instead"
|
||||
)
|
||||
assert "'api.openai.com' in base" not in src, (
|
||||
"Substring OpenAI check still present — use _is_openai_api_base instead"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _join_checked_gallery_endpoint — allowlist enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_join_checked_accepts_known_paths():
|
||||
j = gallery_routes._join_checked_gallery_endpoint
|
||||
assert j("http://localhost:7860/v1", "/images/img2img") == "http://localhost:7860/v1/images/img2img"
|
||||
assert j("http://localhost:7860", "/sdapi/v1/img2img") == "http://localhost:7860/sdapi/v1/img2img"
|
||||
assert j("https://api.openai.com/v1", "/images/edits") == "https://api.openai.com/v1/images/edits"
|
||||
|
||||
|
||||
def test_join_checked_rejects_unknown_path():
|
||||
import pytest
|
||||
j = gallery_routes._join_checked_gallery_endpoint
|
||||
with pytest.raises(ValueError):
|
||||
j("http://localhost/v1", "/arbitrary/user/path")
|
||||
with pytest.raises(ValueError):
|
||||
j("http://localhost/v1", "")
|
||||
with pytest.raises(ValueError):
|
||||
j("http://localhost/v1", "https://evil.test/steal")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-level: no raw str(e) / f"...{e}" returned to API clients
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_raw_exception_string_in_client_responses():
|
||||
src = SRC.read_text()
|
||||
# Patterns that indicate exception internals flowing into client-visible values.
|
||||
# We allow them only in logger calls (checked separately below).
|
||||
bad_patterns = [
|
||||
r'return \{"error": str\(e\)\}',
|
||||
r'return \{"error": f"[^"]*\{e\}[^"]*"\}',
|
||||
r'HTTPException\(\d+, str\(e\)\)',
|
||||
r'HTTPException\(\d+, f"[^"]*\{e\}[^"]*"\)',
|
||||
]
|
||||
for pattern in bad_patterns:
|
||||
matches = re.findall(pattern, src)
|
||||
assert not matches, (
|
||||
f"Pattern {pattern!r} matched — raw exception string returned to client: {matches}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# harmonize: validates _endpoint via check_outbound_url before outbound request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _function_source(src_text: str, func_name: str) -> str:
|
||||
tree = ast.parse(src_text)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
|
||||
return ast.get_source_segment(src_text, node) or ""
|
||||
raise AssertionError(f"{func_name} not found in {SRC}")
|
||||
|
||||
|
||||
def test_harmonize_validates_endpoint_before_fetch():
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "harmonize_image")
|
||||
assert "check_outbound_url" in body, (
|
||||
"harmonize_image must validate _endpoint via check_outbound_url before outbound requests"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# harmonize: target URL only appends constant allowed paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_harmonize_uses_join_checked_for_target_construction():
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "harmonize_image")
|
||||
assert "_join_checked_gallery_endpoint" in body, (
|
||||
"harmonize_image must use _join_checked_gallery_endpoint to build target URLs"
|
||||
)
|
||||
# Raw concatenation patterns that bypass the allowlist must not appear in harmonize
|
||||
assert "base_root + path" not in body, (
|
||||
"harmonize_image must not concatenate base_root + path directly"
|
||||
)
|
||||
assert "base + path" not in body, (
|
||||
"harmonize_image must not concatenate base + path directly"
|
||||
)
|
||||
|
||||
|
||||
def test_gallery_endpoint_paths_allowlist_covers_all_harmonize_candidates():
|
||||
# Every path in the candidates list must be in the pre-approved allowlist.
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "harmonize_image")
|
||||
# Extract string literals that look like route paths from candidates
|
||||
candidate_paths = re.findall(r'"/(?:images|sdapi)/[^"]*"', body)
|
||||
allowed = gallery_routes._GALLERY_ENDPOINT_PATHS
|
||||
for p in candidate_paths:
|
||||
p = p.strip('"')
|
||||
assert p in allowed, (
|
||||
f"Path {p!r} used in harmonize candidates but not in _GALLERY_ENDPOINT_PATHS allowlist"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_openai_api_base — userinfo bypass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_openai_api_base_rejects_userinfo_bypass():
|
||||
# userinfo trick: user = api.openai.com, host = evil.test
|
||||
f = gallery_routes._is_openai_api_base
|
||||
assert f("https://api.openai.com@evil.test/v1") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-level: no client-visible error leaks upstream body fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_httpexception_call(src: str, pos: int) -> str:
|
||||
"""Paren-match from the opening '(' of an HTTPException call."""
|
||||
start = src.index("(", pos)
|
||||
depth = 0
|
||||
for k, ch in enumerate(src[start:]):
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : start + k + 1]
|
||||
return src[start:]
|
||||
|
||||
|
||||
def test_no_upstream_data_in_client_responses():
|
||||
"""No raise HTTPException or return {"error": ...} may expose upstream body data."""
|
||||
src = SRC.read_text()
|
||||
forbidden = [
|
||||
"r.text",
|
||||
"body[:",
|
||||
'data["error"]',
|
||||
"data['error']",
|
||||
"last_err",
|
||||
"{base}",
|
||||
]
|
||||
|
||||
for m in re.finditer(r"\braise\s+HTTPException\s*\(", src):
|
||||
line_start = src.rfind("\n", 0, m.start()) + 1
|
||||
if "logger." in src[line_start : m.start()]:
|
||||
continue
|
||||
call_text = _extract_httpexception_call(src, m.start())
|
||||
for frag in forbidden:
|
||||
assert frag not in call_text, (
|
||||
f"HTTPException raise at byte {m.start()} exposes {frag!r} to client:\n{call_text[:300]}"
|
||||
)
|
||||
|
||||
for m in re.finditer(r'return\s*\{"error":', src):
|
||||
line_end = src.find("\n", m.start())
|
||||
line = src[m.start() : line_end if line_end != -1 else len(src)]
|
||||
for frag in forbidden:
|
||||
assert frag not in line, (
|
||||
f"Error return at byte {m.start()} exposes {frag!r} to client:\n{line}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inpaint_proxy: endpoint construction via _join_checked_gallery_endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_inpaint_uses_join_checked_endpoint():
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "inpaint_proxy")
|
||||
assert 'f"{base}/images/edits"' not in body, (
|
||||
"inpaint_proxy must not build /images/edits via raw f-string"
|
||||
)
|
||||
assert 'f"{base}/images/inpaint"' not in body, (
|
||||
"inpaint_proxy must not build /images/inpaint via raw f-string"
|
||||
)
|
||||
assert '_join_checked_gallery_endpoint(base, "/images/edits")' in body, (
|
||||
"inpaint_proxy must use _join_checked_gallery_endpoint for /images/edits"
|
||||
)
|
||||
assert '_join_checked_gallery_endpoint(base, "/images/inpaint")' in body, (
|
||||
"inpaint_proxy must use _join_checked_gallery_endpoint for /images/inpaint"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# harmonize final 502: no base URL or last_err in client message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_harmonize_final_502_omits_base_and_last_err():
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "harmonize_image")
|
||||
# Collect all HTTPException raises in harmonize and check the last one (final 502)
|
||||
raises = list(re.finditer(r"\braise\s+HTTPException\s*\(", body))
|
||||
assert raises, "harmonize_image must contain at least one raise HTTPException"
|
||||
last_call = _extract_httpexception_call(body, raises[-1].start())
|
||||
for forbidden in ("last_err", "{base}", "r.text"):
|
||||
assert forbidden not in last_call, (
|
||||
f"harmonize final raise exposes {forbidden!r} to client:\n{last_call}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inpaint/harmonize: _endpoint must resolve via DB; no raw admin bypass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_inpaint_endpoint_resolved_via_db_not_raw_input():
|
||||
"""inpaint_proxy must not use the raw request-body value as the outbound base.
|
||||
The user-supplied value is stored as requested_base; outbound base comes from DB."""
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "inpaint_proxy")
|
||||
# requested_base holds the user input; base is only set from ep.base_url
|
||||
assert "requested_base" in body, (
|
||||
"inpaint_proxy must use 'requested_base' for the user-supplied value"
|
||||
)
|
||||
# The admin bypass (not _current_user_is_admin) must not appear in inpaint
|
||||
assert "_current_user_is_admin" not in body, (
|
||||
"inpaint_proxy must not have an admin bypass for raw endpoint resolution"
|
||||
)
|
||||
# If no matching endpoint is found, a 403 must be raised unconditionally
|
||||
assert 'raise HTTPException(403, "Choose a registered image endpoint")' in body, (
|
||||
"inpaint_proxy must raise 403 when _endpoint doesn't match a registered endpoint"
|
||||
)
|
||||
|
||||
|
||||
def test_inpaint_outbound_base_not_from_request_body():
|
||||
"""Confirm _join_checked_gallery_endpoint is never called with the raw
|
||||
request-body variable (requested_base) — only with the DB-derived base."""
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "inpaint_proxy")
|
||||
assert "_join_checked_gallery_endpoint(requested_base," not in body, (
|
||||
"inpaint_proxy must not pass requested_base to _join_checked_gallery_endpoint"
|
||||
)
|
||||
|
||||
|
||||
def test_harmonize_endpoint_resolved_via_db_not_raw_input():
|
||||
"""harmonize_image must not use the raw request-body value as the outbound base."""
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "harmonize_image")
|
||||
assert "requested_base" in body, (
|
||||
"harmonize_image must use 'requested_base' for the user-supplied value"
|
||||
)
|
||||
assert "_current_user_is_admin" not in body, (
|
||||
"harmonize_image must not have an admin bypass for raw endpoint resolution"
|
||||
)
|
||||
assert 'raise HTTPException(403, "Choose a registered image endpoint")' in body, (
|
||||
"harmonize_image must raise 403 when _endpoint doesn't match a registered endpoint"
|
||||
)
|
||||
|
||||
|
||||
def test_harmonize_outbound_base_not_from_request_body():
|
||||
"""Confirm _join_checked_gallery_endpoint is never called with requested_base."""
|
||||
src = SRC.read_text()
|
||||
body = _function_source(src, "harmonize_image")
|
||||
assert "_join_checked_gallery_endpoint(requested_base," not in body, (
|
||||
"harmonize_image must not pass requested_base to _join_checked_gallery_endpoint"
|
||||
)
|
||||
|
||||
|
||||
def test_inpaint_and_harmonize_no_base_equals_endpoint():
|
||||
"""Neither function should assign `base = endpoint` or `base = requested_base`
|
||||
— the outbound base must come exclusively from DB (ep.base_url)."""
|
||||
src = SRC.read_text()
|
||||
for func_name in ("inpaint_proxy", "harmonize_image"):
|
||||
body = _function_source(src, func_name)
|
||||
assert "base = endpoint" not in body, (
|
||||
f"{func_name}: 'base = endpoint' carries request-body input into outbound request"
|
||||
)
|
||||
assert "base = requested_base" not in body, (
|
||||
f"{func_name}: 'base = requested_base' carries request-body input into outbound request"
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Tests for iCalendar TEXT escaping in calendar export (RFC 5545 §3.3.11)."""
|
||||
from tests.test_null_owner_gates import _import_calendar_helpers
|
||||
from tests.helpers.calendar_routes import import_calendar_routes
|
||||
|
||||
|
||||
def _esc():
|
||||
return _import_calendar_helpers()._ics_escape
|
||||
return import_calendar_routes()._ics_escape
|
||||
|
||||
|
||||
def test_escapes_comma_and_semicolon():
|
||||
@@ -26,7 +26,7 @@ def test_empty_and_none_safe():
|
||||
|
||||
|
||||
def test_safe_ics_filename_strips_header_metacharacters():
|
||||
safe_filename = _import_calendar_helpers()._safe_ics_filename
|
||||
safe_filename = import_calendar_routes()._safe_ics_filename
|
||||
|
||||
assert (
|
||||
safe_filename('Work\r\nX-Injected: yes";/..\\evil')
|
||||
@@ -35,7 +35,7 @@ def test_safe_ics_filename_strips_header_metacharacters():
|
||||
|
||||
|
||||
def test_safe_ics_filename_falls_back_for_empty_names():
|
||||
safe_filename = _import_calendar_helpers()._safe_ics_filename
|
||||
safe_filename = import_calendar_routes()._safe_ics_filename
|
||||
|
||||
assert safe_filename("////") == "calendar.ics"
|
||||
assert safe_filename(None) == "calendar.ics"
|
||||
|
||||
@@ -9,6 +9,12 @@ def test_detects_ollama_cloud_native_provider():
|
||||
assert llm_core._detect_provider("https://ollama.com/api/chat") == "ollama"
|
||||
|
||||
|
||||
def test_detects_bare_local_ollama_as_native_provider():
|
||||
assert llm_core._detect_provider("http://localhost:11434") == "ollama"
|
||||
assert llm_core._detect_provider("http://127.0.0.1:11434/") == "ollama"
|
||||
assert llm_core._detect_provider("http://localhost:11434/v1") == "openai"
|
||||
|
||||
|
||||
def test_llm_call_posts_native_ollama_payload(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
@@ -43,6 +49,82 @@ def test_llm_call_posts_native_ollama_payload(monkeypatch):
|
||||
assert seen["json"]["options"] == {"temperature": 0.2, "num_predict": 7}
|
||||
|
||||
|
||||
def test_llm_call_posts_bare_local_ollama_to_native_api(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
seen["url"] = url
|
||||
seen["json"] = json
|
||||
request = httpx.Request("POST", url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={"message": {"content": "OK"}, "done": True},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core.httpx, "post", fake_post)
|
||||
|
||||
result = llm_core.llm_call(
|
||||
"http://localhost:11434",
|
||||
"llama3.2",
|
||||
[{"role": "user", "content": "Say OK"}],
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert seen["url"] == "http://localhost:11434/api/chat"
|
||||
assert seen["json"]["stream"] is False
|
||||
|
||||
|
||||
def test_openai_compatible_chat_url_shapes(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
seen.append(url)
|
||||
request = httpx.Request("POST", url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={"choices": [{"message": {"content": "OK"}}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core.httpx, "post", fake_post)
|
||||
llm_core._response_cache.clear()
|
||||
|
||||
cases = [
|
||||
("http://localhost:11434/v1", "http://localhost:11434/v1/chat/completions"),
|
||||
(
|
||||
"http://localhost:11434/v1/chat/completions",
|
||||
"http://localhost:11434/v1/chat/completions",
|
||||
),
|
||||
]
|
||||
for i, (base_url, expected_url) in enumerate(cases):
|
||||
result = llm_core.llm_call(
|
||||
base_url,
|
||||
f"openai-compatible-{i}",
|
||||
[{"role": "user", "content": f"Say OK {i}"}],
|
||||
)
|
||||
assert result == "OK"
|
||||
assert seen[-1] == expected_url
|
||||
|
||||
|
||||
def test_list_model_ids_from_openai_compatible_v1(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
seen["url"] = url
|
||||
request = httpx.Request("GET", url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={"data": [{"id": "qwen2.5-coder:7b"}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core.httpx, "get", fake_get)
|
||||
|
||||
assert llm_core.list_model_ids("http://localhost:11434/v1") == ["qwen2.5-coder:7b"]
|
||||
assert seen["url"] == "http://localhost:11434/v1/models"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-call argument serialization for native Ollama
|
||||
#
|
||||
|
||||
@@ -18,6 +18,8 @@ import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.helpers.calendar_routes import import_calendar_routes
|
||||
|
||||
# `tests/conftest.py` stubs the heavy optional deps. We additionally
|
||||
# stub `core.database` here because the real module instantiates
|
||||
# SQLAlchemy declarative classes at import-time — which blows up under
|
||||
@@ -64,20 +66,8 @@ from fastapi import HTTPException
|
||||
# calendar._get_or_404_calendar / _get_or_404_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _import_calendar_helpers():
|
||||
"""Import the two private gate helpers without booting the full
|
||||
calendar router. We patch sys.modules so the module-load side
|
||||
effects (DB import) don't blow up under the conftest stubs."""
|
||||
mod_name = "routes.calendar_routes"
|
||||
if mod_name in sys.modules:
|
||||
return sys.modules[mod_name]
|
||||
# core.database is stubbed by conftest already; the module should
|
||||
# import cleanly.
|
||||
return __import__(mod_name, fromlist=["_get_or_404_calendar", "_get_or_404_event"])
|
||||
|
||||
|
||||
def test_calendar_gate_rejects_null_owner_for_authenticated_user():
|
||||
cal_mod = _import_calendar_helpers()
|
||||
cal_mod = import_calendar_routes()
|
||||
db = MagicMock()
|
||||
cal = SimpleNamespace(id="c1", owner=None)
|
||||
db.query.return_value.filter.return_value.first.return_value = cal
|
||||
@@ -87,7 +77,7 @@ def test_calendar_gate_rejects_null_owner_for_authenticated_user():
|
||||
|
||||
|
||||
def test_calendar_gate_rejects_cross_owner():
|
||||
cal_mod = _import_calendar_helpers()
|
||||
cal_mod = import_calendar_routes()
|
||||
db = MagicMock()
|
||||
cal = SimpleNamespace(id="c1", owner="bob")
|
||||
db.query.return_value.filter.return_value.first.return_value = cal
|
||||
@@ -97,7 +87,7 @@ def test_calendar_gate_rejects_cross_owner():
|
||||
|
||||
|
||||
def test_calendar_gate_accepts_matching_owner():
|
||||
cal_mod = _import_calendar_helpers()
|
||||
cal_mod = import_calendar_routes()
|
||||
db = MagicMock()
|
||||
cal = SimpleNamespace(id="c1", owner="alice")
|
||||
db.query.return_value.filter.return_value.first.return_value = cal
|
||||
@@ -106,7 +96,7 @@ def test_calendar_gate_accepts_matching_owner():
|
||||
|
||||
|
||||
def test_calendar_event_gate_rejects_null_owner_calendar():
|
||||
cal_mod = _import_calendar_helpers()
|
||||
cal_mod = import_calendar_routes()
|
||||
db = MagicMock()
|
||||
cal = SimpleNamespace(owner=None)
|
||||
ev = SimpleNamespace(uid="e1", calendar=cal)
|
||||
@@ -117,7 +107,7 @@ def test_calendar_event_gate_rejects_null_owner_calendar():
|
||||
|
||||
|
||||
def test_calendar_event_gate_rejects_cross_owner():
|
||||
cal_mod = _import_calendar_helpers()
|
||||
cal_mod = import_calendar_routes()
|
||||
db = MagicMock()
|
||||
cal = SimpleNamespace(owner="bob")
|
||||
ev = SimpleNamespace(uid="e1", calendar=cal)
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Provider / endpoint resolution tests against the REAL resolver.
|
||||
|
||||
`test_endpoint_resolver.py` deliberately *copies* the pure functions to avoid
|
||||
import side effects. The downside is that those copies silently drift from the
|
||||
shipped code — they already lag `src/endpoint_resolver.py` (no OpenRouter
|
||||
headers, no `anthropic.com` host matching). This module instead imports the
|
||||
real `src.endpoint_resolver`, so it fails the moment the shipped resolution
|
||||
logic stops matching documented provider behavior. `conftest.py` stubs the
|
||||
heavy deps (sqlalchemy, `src.database`), so the import is side-effect free.
|
||||
|
||||
Covers every provider named in ROADMAP.md "Provider setup/probing audit":
|
||||
Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, DeepSeek — plus Ollama
|
||||
(local + cloud) and the Tailscale self-host fallback.
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver as er
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dns(monkeypatch):
|
||||
"""Neutralize resolve_url so URL-building tests never touch DNS/Tailscale.
|
||||
|
||||
build_chat_url/build_models_url call the module-global resolve_url first;
|
||||
patching it on the module makes those calls a no-op (functions resolve
|
||||
globals by name at call time).
|
||||
"""
|
||||
monkeypatch.setattr(er, "resolve_url", lambda u: u)
|
||||
|
||||
|
||||
# (id, base_url, expected_chat_url, expected_models_url)
|
||||
PROVIDER_CASES = [
|
||||
("openai", "https://api.openai.com/v1",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/models"),
|
||||
("openai_pathless", "https://api.openai.com",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/models"),
|
||||
("anthropic", "https://api.anthropic.com",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
"https://api.anthropic.com/v1/models"),
|
||||
# Anthropic base that already carries /v1 must not become /v1/v1/messages.
|
||||
("anthropic_v1", "https://api.anthropic.com/v1",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
"https://api.anthropic.com/v1/models"),
|
||||
("openrouter", "https://openrouter.ai/api/v1",
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
"https://openrouter.ai/api/v1/models"),
|
||||
("groq", "https://api.groq.com/openai/v1",
|
||||
"https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.groq.com/openai/v1/models"),
|
||||
("nvidia", "https://integrate.api.nvidia.com/v1",
|
||||
"https://integrate.api.nvidia.com/v1/chat/completions",
|
||||
"https://integrate.api.nvidia.com/v1/models"),
|
||||
("xai", "https://api.x.ai/v1",
|
||||
"https://api.x.ai/v1/chat/completions",
|
||||
"https://api.x.ai/v1/models"),
|
||||
("deepseek", "https://api.deepseek.com",
|
||||
"https://api.deepseek.com/chat/completions",
|
||||
"https://api.deepseek.com/v1/models"),
|
||||
# Gemini's OpenAI-compatible surface — treated as a generic OpenAI endpoint.
|
||||
("gemini_openai", "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/models"),
|
||||
("ollama_local", "http://localhost:11434/api",
|
||||
"http://localhost:11434/api/chat",
|
||||
"http://localhost:11434/api/tags"),
|
||||
("ollama_cloud", "https://ollama.com",
|
||||
"https://ollama.com/api/chat",
|
||||
"https://ollama.com/api/tags"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base,expected", [(c[1], c[2]) for c in PROVIDER_CASES],
|
||||
ids=[c[0] for c in PROVIDER_CASES],
|
||||
)
|
||||
def test_build_chat_url(no_dns, base, expected):
|
||||
assert er.build_chat_url(base) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base,expected", [(c[1], c[3]) for c in PROVIDER_CASES],
|
||||
ids=[c[0] for c in PROVIDER_CASES],
|
||||
)
|
||||
def test_build_models_url(no_dns, base, expected):
|
||||
assert er.build_models_url(base) == expected
|
||||
|
||||
|
||||
def test_chat_url_never_double_prefixes_anthropic(no_dns):
|
||||
"""Regression guard: the /v1 collapse must not produce /v1/v1/messages."""
|
||||
url = er.build_chat_url("https://api.anthropic.com/v1")
|
||||
assert "/v1/v1/" not in url
|
||||
assert url.count("/v1/messages") == 1
|
||||
|
||||
|
||||
# ── Auth headers per provider ──
|
||||
|
||||
def test_headers_anthropic_uses_x_api_key():
|
||||
h = er.build_headers("secret", "https://api.anthropic.com")
|
||||
assert h["x-api-key"] == "secret"
|
||||
assert h["anthropic-version"] == "2023-06-01"
|
||||
assert "Authorization" not in h
|
||||
|
||||
|
||||
def test_headers_anthropic_without_key_still_sends_version():
|
||||
h = er.build_headers(None, "https://api.anthropic.com")
|
||||
assert h["anthropic-version"] == "2023-06-01"
|
||||
assert "x-api-key" not in h
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base", [
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.x.ai/v1",
|
||||
"https://api.deepseek.com",
|
||||
"https://api.groq.com/openai/v1",
|
||||
"https://integrate.api.nvidia.com/v1",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
])
|
||||
def test_headers_openai_style_use_bearer(base):
|
||||
h = er.build_headers("secret", base)
|
||||
assert h["Authorization"] == "Bearer secret"
|
||||
assert "HTTP-Referer" not in h
|
||||
assert "x-api-key" not in h
|
||||
|
||||
|
||||
def test_headers_openrouter_adds_attribution():
|
||||
h = er.build_headers("secret", "https://openrouter.ai/api/v1")
|
||||
assert h["Authorization"] == "Bearer secret"
|
||||
# OpenRouter ranks/labels apps via these headers.
|
||||
assert h["HTTP-Referer"].startswith("https://github.com/")
|
||||
assert h["X-OpenRouter-Title"] == "Odysseus"
|
||||
|
||||
|
||||
def test_headers_omit_authorization_when_no_key():
|
||||
assert er.build_headers(None, "https://api.openai.com/v1") == {}
|
||||
|
||||
|
||||
# ── normalize_base: strip whatever path the user pasted ──
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1"),
|
||||
("https://api.openai.com/v1/completions", "https://api.openai.com/v1"),
|
||||
("https://api.openai.com/v1/models/", "https://api.openai.com/v1"),
|
||||
("https://api.anthropic.com/v1/messages", "https://api.anthropic.com"),
|
||||
("http://localhost:11434/api/chat", "http://localhost:11434/api"),
|
||||
("http://localhost:11434/api/tags", "http://localhost:11434/api"),
|
||||
("http://localhost:11434/api/generate", "http://localhost:11434/api"),
|
||||
("https://api.openai.com/v1/", "https://api.openai.com/v1"),
|
||||
(" https://api.openai.com/v1 ", "https://api.openai.com/v1"),
|
||||
("", ""),
|
||||
(None, ""),
|
||||
])
|
||||
def test_normalize_base(raw, expected):
|
||||
assert er.normalize_base(raw) == expected
|
||||
|
||||
|
||||
# ── _first_chat_model: never auto-pick an embedding/tts/etc. model ──
|
||||
|
||||
def test_first_chat_model_skips_non_chat():
|
||||
models = ["text-embedding-ada-002", "whisper-1", "gpt-4o", "dall-e-3"]
|
||||
assert er._first_chat_model(models) == "gpt-4o"
|
||||
|
||||
|
||||
def test_first_chat_model_falls_back_to_first_when_all_non_chat():
|
||||
models = ["text-embedding-3-large", "text-embedding-3-small"]
|
||||
assert er._first_chat_model(models) == "text-embedding-3-large"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("models", [[], None])
|
||||
def test_first_chat_model_empty(models):
|
||||
assert er._first_chat_model(models) is None
|
||||
|
||||
|
||||
# ── provider-root helpers ──
|
||||
|
||||
@pytest.mark.parametrize("base,expected", [
|
||||
("https://api.anthropic.com/v1", "https://api.anthropic.com"),
|
||||
("https://api.anthropic.com", "https://api.anthropic.com"),
|
||||
# /v1 on a non-Anthropic host (OpenAI-compatible) must be preserved.
|
||||
("https://api.openai.com/v1", "https://api.openai.com/v1"),
|
||||
])
|
||||
def test_anthropic_api_root(base, expected):
|
||||
assert er._anthropic_api_root(base) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base,expected", [
|
||||
("https://ollama.com", "https://ollama.com/api"),
|
||||
("http://localhost:11434/api", "http://localhost:11434/api"),
|
||||
# A non-Ollama host is returned untouched.
|
||||
("https://api.openai.com/v1", "https://api.openai.com/v1"),
|
||||
])
|
||||
def test_ollama_api_root(base, expected):
|
||||
assert er._ollama_api_root(base) == expected
|
||||
|
||||
|
||||
# ── resolve_url: Tailscale self-host fallback ──
|
||||
# ROADMAP flags plain-HTTP Tailscale URLs as a self-host trap; resolve_url is
|
||||
# the hop that rewrites an unresolvable hostname to its Tailscale IP.
|
||||
|
||||
class TestResolveUrlTailscale:
|
||||
def setup_method(self):
|
||||
# The module memoizes hostname→IP; clear it so cases don't bleed.
|
||||
er._tailscale_cache.clear()
|
||||
|
||||
def test_dns_success_returns_url_unchanged(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
er.socket, "getaddrinfo",
|
||||
lambda *a, **k: [(2, 1, 6, "", ("1.2.3.4", 0))],
|
||||
)
|
||||
assert er.resolve_url("http://myhost:7000/api") == "http://myhost:7000/api"
|
||||
|
||||
def test_dns_failure_rewrites_to_tailscale_ip(self, monkeypatch):
|
||||
def _fail(*a, **k):
|
||||
raise socket.gaierror("no DNS")
|
||||
monkeypatch.setattr(er.socket, "getaddrinfo", _fail)
|
||||
peers = {"Peer": {"x": {
|
||||
"HostName": "myhost",
|
||||
"DNSName": "myhost.tail.ts.net.",
|
||||
"TailscaleIPs": ["100.64.0.5"],
|
||||
}}}
|
||||
monkeypatch.setattr(
|
||||
er.subprocess, "run",
|
||||
lambda *a, **k: types.SimpleNamespace(returncode=0, stdout=json.dumps(peers)),
|
||||
)
|
||||
# Port is preserved, host swapped for the Tailscale IP.
|
||||
assert er.resolve_url("http://myhost:7000/api") == "http://100.64.0.5:7000/api"
|
||||
|
||||
def test_dns_failure_no_peer_match_keeps_url(self, monkeypatch):
|
||||
def _fail(*a, **k):
|
||||
raise socket.gaierror("no DNS")
|
||||
monkeypatch.setattr(er.socket, "getaddrinfo", _fail)
|
||||
monkeypatch.setattr(
|
||||
er.subprocess, "run",
|
||||
lambda *a, **k: types.SimpleNamespace(returncode=0, stdout=json.dumps({"Peer": {}})),
|
||||
)
|
||||
assert er.resolve_url("http://myhost:7000/api") == "http://myhost:7000/api"
|
||||
|
||||
def test_url_without_hostname_is_returned_as_is(self):
|
||||
assert er.resolve_url("") == ""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Provider endpoint auth-header tests.
|
||||
|
||||
Covers ``build_headers`` for every provider: Anthropic (x-api-key + version
|
||||
header), OpenAI-style providers (Bearer token), OpenRouter (Bearer + attribution
|
||||
headers), and the no-key case.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver as er
|
||||
|
||||
|
||||
def test_headers_anthropic_uses_x_api_key():
|
||||
h = er.build_headers("secret", "https://api.anthropic.com")
|
||||
assert h["x-api-key"] == "secret"
|
||||
assert h["anthropic-version"] == "2023-06-01"
|
||||
assert "Authorization" not in h
|
||||
|
||||
|
||||
def test_headers_anthropic_without_key_still_sends_version():
|
||||
h = er.build_headers(None, "https://api.anthropic.com")
|
||||
assert h["anthropic-version"] == "2023-06-01"
|
||||
assert "x-api-key" not in h
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base", [
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.x.ai/v1",
|
||||
"https://api.deepseek.com",
|
||||
"https://api.groq.com/openai/v1",
|
||||
"https://integrate.api.nvidia.com/v1",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
])
|
||||
def test_headers_openai_style_use_bearer(base):
|
||||
h = er.build_headers("secret", base)
|
||||
assert h["Authorization"] == "Bearer secret"
|
||||
assert "HTTP-Referer" not in h
|
||||
assert "x-api-key" not in h
|
||||
|
||||
|
||||
def test_headers_openrouter_adds_attribution():
|
||||
h = er.build_headers("secret", "https://openrouter.ai/api/v1")
|
||||
assert h["Authorization"] == "Bearer secret"
|
||||
# OpenRouter ranks/labels apps via these headers.
|
||||
assert h["HTTP-Referer"].startswith("https://github.com/")
|
||||
assert h["X-OpenRouter-Title"] == "Odysseus"
|
||||
|
||||
|
||||
def test_headers_omit_authorization_when_no_key():
|
||||
assert er.build_headers(None, "https://api.openai.com/v1") == {}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Provider endpoint model-selection tests.
|
||||
|
||||
Covers ``_first_chat_model``: auto-picking the first usable chat model from a
|
||||
provider's model list, skipping embedding/tts/image models when possible.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver as er
|
||||
|
||||
|
||||
# ── _first_chat_model: never auto-pick an embedding/tts/etc. model ──
|
||||
|
||||
def test_first_chat_model_skips_non_chat():
|
||||
models = ["text-embedding-ada-002", "whisper-1", "gpt-4o", "dall-e-3"]
|
||||
assert er._first_chat_model(models) == "gpt-4o"
|
||||
|
||||
|
||||
def test_first_chat_model_falls_back_to_first_when_all_non_chat():
|
||||
models = ["text-embedding-3-large", "text-embedding-3-small"]
|
||||
assert er._first_chat_model(models) == "text-embedding-3-large"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("models", [[], None])
|
||||
def test_first_chat_model_empty(models):
|
||||
assert er._first_chat_model(models) is None
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Provider endpoint normalization tests.
|
||||
|
||||
Covers ``normalize_base`` (strip whatever path the user pasted), and the
|
||||
provider-root helpers ``_anthropic_api_root`` and ``_ollama_api_root``.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver as er
|
||||
|
||||
|
||||
# ── normalize_base: strip whatever path the user pasted ──
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1"),
|
||||
("https://api.openai.com/v1/completions", "https://api.openai.com/v1"),
|
||||
("https://api.openai.com/v1/models/", "https://api.openai.com/v1"),
|
||||
("https://api.anthropic.com/v1/messages", "https://api.anthropic.com"),
|
||||
("http://localhost:11434/api/chat", "http://localhost:11434/api"),
|
||||
("http://localhost:11434/api/tags", "http://localhost:11434/api"),
|
||||
("http://localhost:11434/api/generate", "http://localhost:11434/api"),
|
||||
("https://api.openai.com/v1/", "https://api.openai.com/v1"),
|
||||
(" https://api.openai.com/v1 ", "https://api.openai.com/v1"),
|
||||
("", ""),
|
||||
(None, ""),
|
||||
])
|
||||
def test_normalize_base(raw, expected):
|
||||
assert er.normalize_base(raw) == expected
|
||||
|
||||
|
||||
# ── provider-root helpers ──
|
||||
|
||||
@pytest.mark.parametrize("base,expected", [
|
||||
("https://api.anthropic.com/v1", "https://api.anthropic.com"),
|
||||
("https://api.anthropic.com", "https://api.anthropic.com"),
|
||||
# /v1 on a non-Anthropic host (OpenAI-compatible) must be preserved.
|
||||
("https://api.openai.com/v1", "https://api.openai.com/v1"),
|
||||
])
|
||||
def test_anthropic_api_root(base, expected):
|
||||
assert er._anthropic_api_root(base) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base,expected", [
|
||||
("https://ollama.com", "https://ollama.com/api"),
|
||||
("http://localhost:11434/api", "http://localhost:11434/api"),
|
||||
# A non-Ollama host is returned untouched.
|
||||
("https://api.openai.com/v1", "https://api.openai.com/v1"),
|
||||
])
|
||||
def test_ollama_api_root(base, expected):
|
||||
assert er._ollama_api_root(base) == expected
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Provider endpoint Tailscale URL-resolution tests.
|
||||
|
||||
Covers ``resolve_url``: the hop that rewrites an unresolvable hostname to its
|
||||
Tailscale IP. ROADMAP flags plain-HTTP Tailscale URLs as a self-host trap;
|
||||
resolve_url is the gate that handles that fallback.
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver as er
|
||||
|
||||
|
||||
# ── resolve_url: Tailscale self-host fallback ──
|
||||
# ROADMAP flags plain-HTTP Tailscale URLs as a self-host trap; resolve_url is
|
||||
# the hop that rewrites an unresolvable hostname to its Tailscale IP.
|
||||
|
||||
class TestResolveUrlTailscale:
|
||||
def setup_method(self):
|
||||
# The module memoizes hostname→IP; clear it so cases don't bleed.
|
||||
er._tailscale_cache.clear()
|
||||
|
||||
def test_dns_success_returns_url_unchanged(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
er.socket, "getaddrinfo",
|
||||
lambda *a, **k: [(2, 1, 6, "", ("1.2.3.4", 0))],
|
||||
)
|
||||
assert er.resolve_url("http://myhost:7000/api") == "http://myhost:7000/api"
|
||||
|
||||
def test_dns_failure_rewrites_to_tailscale_ip(self, monkeypatch):
|
||||
def _fail(*a, **k):
|
||||
raise socket.gaierror("no DNS")
|
||||
monkeypatch.setattr(er.socket, "getaddrinfo", _fail)
|
||||
peers = {"Peer": {"x": {
|
||||
"HostName": "myhost",
|
||||
"DNSName": "myhost.tail.ts.net.",
|
||||
"TailscaleIPs": ["100.64.0.5"],
|
||||
}}}
|
||||
monkeypatch.setattr(
|
||||
er.subprocess, "run",
|
||||
lambda *a, **k: types.SimpleNamespace(returncode=0, stdout=json.dumps(peers)),
|
||||
)
|
||||
# Port is preserved, host swapped for the Tailscale IP.
|
||||
assert er.resolve_url("http://myhost:7000/api") == "http://100.64.0.5:7000/api"
|
||||
|
||||
def test_dns_failure_no_peer_match_keeps_url(self, monkeypatch):
|
||||
def _fail(*a, **k):
|
||||
raise socket.gaierror("no DNS")
|
||||
monkeypatch.setattr(er.socket, "getaddrinfo", _fail)
|
||||
monkeypatch.setattr(
|
||||
er.subprocess, "run",
|
||||
lambda *a, **k: types.SimpleNamespace(returncode=0, stdout=json.dumps({"Peer": {}})),
|
||||
)
|
||||
assert er.resolve_url("http://myhost:7000/api") == "http://myhost:7000/api"
|
||||
|
||||
def test_url_without_hostname_is_returned_as_is(self):
|
||||
assert er.resolve_url("") == ""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Provider endpoint URL-building tests.
|
||||
|
||||
Covers ``build_chat_url`` and ``build_models_url`` for every provider named in
|
||||
ROADMAP.md: Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, DeepSeek, Ollama
|
||||
(local + cloud).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver as er
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dns(monkeypatch):
|
||||
"""Neutralize resolve_url so URL-building tests never touch DNS/Tailscale.
|
||||
|
||||
build_chat_url/build_models_url call the module-global resolve_url first;
|
||||
patching it on the module makes those calls a no-op (functions resolve
|
||||
globals by name at call time).
|
||||
"""
|
||||
monkeypatch.setattr(er, "resolve_url", lambda u: u)
|
||||
|
||||
|
||||
# (id, base_url, expected_chat_url, expected_models_url)
|
||||
PROVIDER_CASES = [
|
||||
("openai", "https://api.openai.com/v1",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/models"),
|
||||
("openai_pathless", "https://api.openai.com",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/models"),
|
||||
("anthropic", "https://api.anthropic.com",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
"https://api.anthropic.com/v1/models"),
|
||||
# Anthropic base that already carries /v1 must not become /v1/v1/messages.
|
||||
("anthropic_v1", "https://api.anthropic.com/v1",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
"https://api.anthropic.com/v1/models"),
|
||||
("openrouter", "https://openrouter.ai/api/v1",
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
"https://openrouter.ai/api/v1/models"),
|
||||
("groq", "https://api.groq.com/openai/v1",
|
||||
"https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.groq.com/openai/v1/models"),
|
||||
("nvidia", "https://integrate.api.nvidia.com/v1",
|
||||
"https://integrate.api.nvidia.com/v1/chat/completions",
|
||||
"https://integrate.api.nvidia.com/v1/models"),
|
||||
("xai", "https://api.x.ai/v1",
|
||||
"https://api.x.ai/v1/chat/completions",
|
||||
"https://api.x.ai/v1/models"),
|
||||
("deepseek", "https://api.deepseek.com",
|
||||
"https://api.deepseek.com/chat/completions",
|
||||
"https://api.deepseek.com/v1/models"),
|
||||
# Gemini's OpenAI-compatible surface — treated as a generic OpenAI endpoint.
|
||||
("gemini_openai", "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/models"),
|
||||
("ollama_local", "http://localhost:11434/api",
|
||||
"http://localhost:11434/api/chat",
|
||||
"http://localhost:11434/api/tags"),
|
||||
("ollama_cloud", "https://ollama.com",
|
||||
"https://ollama.com/api/chat",
|
||||
"https://ollama.com/api/tags"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base,expected", [(c[1], c[2]) for c in PROVIDER_CASES],
|
||||
ids=[c[0] for c in PROVIDER_CASES],
|
||||
)
|
||||
def test_build_chat_url(no_dns, base, expected):
|
||||
assert er.build_chat_url(base) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base,expected", [(c[1], c[3]) for c in PROVIDER_CASES],
|
||||
ids=[c[0] for c in PROVIDER_CASES],
|
||||
)
|
||||
def test_build_models_url(no_dns, base, expected):
|
||||
assert er.build_models_url(base) == expected
|
||||
|
||||
|
||||
def test_chat_url_never_double_prefixes_anthropic(no_dns):
|
||||
"""Regression guard: the /v1 collapse must not produce /v1/v1/messages."""
|
||||
url = er.build_chat_url("https://api.anthropic.com/v1")
|
||||
assert "/v1/v1/" not in url
|
||||
assert url.count("/v1/messages") == 1
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Regression test for the research route shim (slice 2b, #4082/#4071).
|
||||
|
||||
The backward-compat shim at ``routes/research_routes.py`` uses ``sys.modules``
|
||||
replacement so the legacy import path and the canonical ``routes.research.*``
|
||||
path resolve to the *same* module object. This is required because
|
||||
``test_research_owner_scope_routes.py`` does a string-targeted
|
||||
``monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", ...)`` which
|
||||
must reach the canonical module. This test pins that contract.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import routes.research_routes as _shim_research # noqa: F401
|
||||
|
||||
|
||||
def test_legacy_and_canonical_research_module_are_same_object():
|
||||
"""``import routes.research_routes`` must alias the canonical module."""
|
||||
legacy = importlib.import_module("routes.research_routes")
|
||||
canonical = importlib.import_module("routes.research.research_routes")
|
||||
assert legacy is canonical, (
|
||||
"routes.research_routes shim must resolve to the canonical "
|
||||
"routes.research.research_routes module object"
|
||||
)
|
||||
|
||||
|
||||
def test_string_targeted_monkeypatch_reaches_canonical(monkeypatch):
|
||||
"""String-targeted ``monkeypatch.setattr`` via the legacy path must reach
|
||||
the canonical module.
|
||||
|
||||
``test_research_owner_scope_routes.py`` patches
|
||||
``"routes.research_routes.DEEP_RESEARCH_DIR"`` as an autouse fixture; for
|
||||
that to take effect at runtime, the legacy module name and the canonical
|
||||
module must be identical.
|
||||
"""
|
||||
legacy = importlib.import_module("routes.research_routes")
|
||||
canonical = importlib.import_module("routes.research.research_routes")
|
||||
|
||||
sentinel = "/tmp/shim-test-sentinel"
|
||||
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", sentinel)
|
||||
assert canonical.DEEP_RESEARCH_DIR == sentinel, (
|
||||
"string-targeted monkeypatch via legacy path did not reach the canonical module"
|
||||
)
|
||||
# restore is handled by monkeypatch fixture teardown
|
||||
assert legacy is canonical
|
||||
Reference in New Issue
Block a user