mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Compare commits
6 Commits
827a6b2778
...
893e490cdc
| Author | SHA1 | Date | |
|---|---|---|---|
| 893e490cdc | |||
| bad9ec2f9c | |||
| 927b1f7ecf | |||
| bb2148db73 | |||
| e018c7cf6c | |||
| a7fc1343a3 |
@@ -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 (";", "&&", "||", "$(")):
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-1
@@ -22,6 +22,16 @@ from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Last-resort verdict extraction from a teacher/verifier model's prose (run when
|
||||
# JSON parsing fails). `["\'\s:]*` already consumes whitespace, so the original
|
||||
# trailing `\s*` made two adjacent \s-matching quantifiers that backtrack O(n^2)
|
||||
# on a `verdict` + whitespace flood in untrusted model output (CodeQL
|
||||
# py/polynomial-redos). Without it a single unbounded quantifier remains — the
|
||||
# matched text is identical, and the scan is linear.
|
||||
_VERDICT_PROSE_RE = re.compile(
|
||||
r'verdict["\'\s:]*["\']?(pass|needs_work|fail|inconclusive)', re.I
|
||||
)
|
||||
|
||||
|
||||
class SkillAddRequest(BaseModel):
|
||||
# New schema (preferred)
|
||||
@@ -196,7 +206,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
# Last resort: pull the verdict keyword straight out of the prose so a
|
||||
# clearly-decided run isn't thrown away as "unparseable".
|
||||
if v not in _VERDICTS:
|
||||
km = _re.search(r'verdict["\'\s:]*\s*["\']?(pass|needs_work|fail|inconclusive)', text, _re.I)
|
||||
km = _VERDICT_PROSE_RE.search(text)
|
||||
if km:
|
||||
v = km.group(1).lower()
|
||||
if data is None:
|
||||
|
||||
+6
-1
@@ -845,7 +845,12 @@ _EXPLICIT_CONTINUATION_RE = re.compile(
|
||||
r"run it|launch it|start it|use that|that one|same|the same|"
|
||||
r"first|second|third|the first one|the second one|the third one|"
|
||||
r"[123]|[abc]"
|
||||
r")\s*[.!?]*\s*$",
|
||||
# `\s*[.!?]*\s*$` put two \s-matching quantifiers around `[.!?]*`, which
|
||||
# backtracks O(n^2) on a terse reply + whitespace flood (py/polynomial-redos).
|
||||
# `\s*(?:[.!?]+\s*)?$` accepts the same "trailing space/punctuation" tails
|
||||
# (the inner \s* only engages after `[.!?]+`, so no two \s* are adjacent) and
|
||||
# is linear.
|
||||
r")\s*(?:[.!?]+\s*)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RETRY_CONTINUATION_RE = re.compile(
|
||||
|
||||
+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,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,80 @@
|
||||
"""Regression tests for two py/polynomial-redos sinks over untrusted model text.
|
||||
|
||||
Both had two adjacent `\\s`-matching quantifiers that backtrack O(n^2) when the
|
||||
rest of the pattern fails on a whitespace flood:
|
||||
|
||||
* `routes/skills_routes.py` `_VERDICT_PROSE_RE` — `["\\'\\s:]*\\s*` (the class
|
||||
already matches `\\s`) over a teacher/verifier model's prose verdict.
|
||||
* `src/agent_loop.py` `_EXPLICIT_CONTINUATION_RE` — `\\s*[.!?]*\\s*$` over a
|
||||
user's terse reply.
|
||||
|
||||
Each is rewritten to drop the adjacency while keeping the exact match set. The
|
||||
tests pin correctness (matches unchanged) and bound the flood inputs; the old
|
||||
patterns took seconds, the loose budget is seconds, so the margin is ~100x.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import src.agent_tools # noqa: F401 (break agent_tools<->agent_loop import cycle)
|
||||
from routes.skills_routes import _VERDICT_PROSE_RE
|
||||
from src.agent_loop import _EXPLICIT_CONTINUATION_RE, _is_explicit_continuation
|
||||
|
||||
_BUDGET_S = 4.0
|
||||
|
||||
|
||||
def _timed(fn, *args):
|
||||
start = time.perf_counter()
|
||||
result = fn(*args)
|
||||
return result, time.perf_counter() - start
|
||||
|
||||
|
||||
# ── #229 verdict-from-prose: matches unchanged ──────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
('verdict": "FAIL"', "fail"),
|
||||
("verdict needs_work", "needs_work"),
|
||||
("Verdict: inconclusive", "inconclusive"),
|
||||
("verdict\t\t'pass'", "pass"),
|
||||
("verdictpass", "pass"), # all separators optional — keyword may abut, as before
|
||||
("the verdict is: pass overall", None), # intervening "is" breaks the run
|
||||
("no clear decision here", None),
|
||||
])
|
||||
def test_verdict_prose_extraction(text, expected):
|
||||
m = _VERDICT_PROSE_RE.search(text)
|
||||
assert (m.group(1).lower() if m else None) == expected
|
||||
|
||||
|
||||
def test_verdict_prose_flood_is_fast():
|
||||
evil = "verdict" + "\t" * 40000 + "x" # `verdict` then whitespace, no keyword
|
||||
(m, dt) = _timed(_VERDICT_PROSE_RE.search, evil)
|
||||
assert dt < _BUDGET_S, f"_VERDICT_PROSE_RE took {dt:.2f}s"
|
||||
assert m is None
|
||||
|
||||
|
||||
# ── #472 explicit-continuation: classification unchanged ────────────────────
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"yes", "y", "ok!", "okay ...", "sure!!", "do it", "1", "a", "2.",
|
||||
"the second one", " yes ", "continue", "run it!", "third???",
|
||||
])
|
||||
def test_continuation_accepts_terse_confirmations(text):
|
||||
assert _is_explicit_continuation(text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"no", "maybe yes", "yesx", "let's not", "y . ! .", "", "run the script please",
|
||||
])
|
||||
def test_continuation_rejects_non_confirmations(text):
|
||||
assert not _is_explicit_continuation(text)
|
||||
|
||||
|
||||
def test_continuation_flood_is_fast():
|
||||
evil = "y" + "\t" * 40000 + "x" # terse opener then whitespace flood, no `$`
|
||||
(_, dt) = _timed(_is_explicit_continuation, evil)
|
||||
assert dt < _BUDGET_S, f"_is_explicit_continuation took {dt:.2f}s"
|
||||
# Direct on the compiled pattern too (the function strips first).
|
||||
(m, dt2) = _timed(_EXPLICIT_CONTINUATION_RE.match, evil)
|
||||
assert dt2 < _BUDGET_S, f"_EXPLICIT_CONTINUATION_RE took {dt2:.2f}s"
|
||||
assert m is None
|
||||
@@ -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