Coverage for src/local_deep_research/web/routers/api.py: 99%

203 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1from fastapi import APIRouter, Depends, Request 

2from fastapi.responses import JSONResponse 

3from ..dependencies.auth import require_auth 

4from ..dependencies.threadpool import run_db_sync 

5 

6import requests 

7 

8from loguru import logger 

9 

10from ...llm.providers.base import normalize_provider 

11from ...constants import ResearchStatus 

12from ...database.models import QueuedResearch, ResearchHistory 

13from ...database.session_context import get_user_db_session 

14from ...config.constants import DEFAULT_OLLAMA_URL 

15from ...utilities.url_utils import normalize_url 

16 

17from .research import _research_not_found 

18from ..services.research_service import ( 

19 cancel_research, 

20) 

21from ..services.resource_service import ( 

22 add_resource, 

23 delete_resource, 

24 get_resources_for_research, 

25) 

26from local_deep_research.settings import SettingsManager 

27from ...security import safe_get, strip_settings_snapshot 

28from ..dependencies.json_body import json_body_error 

29from typing import Annotated 

30 

31# Create the router 

32router = APIRouter(prefix="/research/api", tags=["api"]) 

33 

34# NOTE: Routes use username (not .get()) intentionally. 

35# Depends(require_auth) guarantees the key exists; direct access fails 

36# fast if the dependency is ever removed. 

37 

38 

39@router.get("/settings/current-config") 

40def get_current_config( 

41 request: Request, username: Annotated[str, Depends(require_auth)] 

42): 

43 """Get the current configuration from database settings.""" 

44 try: 

45 with get_user_db_session(username) as session: 

46 settings_manager = SettingsManager(session) 

47 config = { 

48 "provider": settings_manager.get_setting( 

49 "llm.provider", "Not configured" 

50 ), 

51 "model": settings_manager.get_setting( 

52 "llm.model", "Not configured" 

53 ), 

54 "search_tool": settings_manager.get_setting( 

55 "search.tool", "searxng" 

56 ), 

57 "iterations": settings_manager.get_setting( 

58 "search.iterations", 8 

59 ), 

60 "questions_per_iteration": settings_manager.get_setting( 

61 "search.questions_per_iteration", 5 

62 ), 

63 "search_strategy": settings_manager.get_setting( 

64 "search.search_strategy", "focused_iteration" 

65 ), 

66 } 

67 

68 return {"success": True, "config": config} 

69 

70 except Exception: 

71 logger.exception("Error getting current config") 

72 return JSONResponse( 

73 {"success": False, "error": "An internal error occurred"}, 

74 status_code=500, 

75 ) 

76 

77 

78# API Routes 

79@router.post("/start") 

80async def api_start_research( 

81 request: Request, username: Annotated[str, Depends(require_auth)] 

82): 

83 """ 

84 Start a new research process. 

85 

86 Delegates to the full-featured start_research() in the research router, 

87 which reads settings from the database, handles queueing, and starts 

88 the research thread. 

89 """ 

90 from .research import start_research 

91 

92 return await start_research(request=request, username=username) 

93 

94 

95@router.get("/status/{research_id}") 

96def api_research_status( 

97 request: Request, 

98 research_id, 

99 username: Annotated[str, Depends(require_auth)], 

100): 

101 """ 

102 Get the status of a research process 

103 """ 

104 try: 

105 # Get a fresh session to avoid conflicts with the research process 

106 

107 with get_user_db_session(username) as db_session: 

108 research = ( 

109 db_session.query(ResearchHistory) 

110 .filter_by(id=research_id) 

111 .first() 

112 ) 

113 

114 if research is None: 

115 return _research_not_found(research_id) 

116 

117 # Extract attributes while session is active 

118 # to avoid DetachedInstanceError after the with block exits 

119 result = { 

120 "status": research.status, 

121 "progress": research.progress, 

122 "completed_at": research.completed_at, 

123 "report_path": research.report_path, 

124 "metadata": strip_settings_snapshot(research.research_meta), 

125 } 

126 

127 # Include queue position for queued research so the progress 

128 # page can show "position N in queue" (#3283). 

129 if research.status == ResearchStatus.QUEUED: 

130 queued = ( 

131 db_session.query(QueuedResearch) 

132 .filter_by(research_id=research_id) 

133 .first() 

134 ) 

135 if queued: 135 ↛ 138line 135 didn't jump to line 138 because the condition on line 135 was always true

136 result["queue_position"] = queued.position 

137 

138 return result 

139 

140 except Exception: 

141 logger.exception("Error getting research status") 

142 return JSONResponse( 

143 {"status": "error", "message": "Failed to get research status"}, 

144 status_code=500, 

145 ) 

146 

147 

148@router.post("/terminate/{research_id}") 

149def api_terminate_research( 

150 request: Request, 

151 research_id, 

152 username: Annotated[str, Depends(require_auth)], 

153): 

154 """ 

155 Terminate a research process 

156 """ 

157 try: 

158 result = cancel_research(research_id, username) 

159 if result: 

160 return { 

161 "status": "success", 

162 "message": "Research terminated", 

163 "result": result, 

164 } 

165 

166 return { 

167 "status": "success", 

168 "message": "Research not found or already completed", 

169 "result": result, 

170 } 

171 

172 except Exception: 

173 logger.exception("Error terminating research") 

174 return JSONResponse( 

175 {"status": "error", "message": "Failed to stop research."}, 

176 status_code=500, 

177 ) 

178 

179 

180@router.get("/resources/{research_id}") 

181def api_get_resources( 

182 request: Request, 

183 research_id, 

184 username: Annotated[str, Depends(require_auth)], 

185): 

186 """ 

187 Get resources for a specific research. 

188 

189 User-scoped: each user has their own encrypted database, so a 

190 ``research_id`` belonging to another user simply yields an empty 

191 list rather than leaking their data. 

192 """ 

193 try: 

194 resources = get_resources_for_research(research_id, username) 

195 return {"status": "success", "resources": resources} 

196 except Exception: 

197 logger.exception("Error getting resources for research") 

198 return JSONResponse( 

199 {"status": "error", "message": "Failed to get resources"}, 

200 status_code=500, 

201 ) 

202 

203 

204@router.post("/resources/{research_id}") 

205async def api_add_resource( 

206 request: Request, 

207 research_id, 

208 username: Annotated[str, Depends(require_auth)], 

209): 

210 """ 

211 Add a new resource to a research project 

212 """ 

213 

214 data = await request.json() 

215 if not isinstance(data, dict): 

216 return json_body_error("status", "Request body must be valid JSON") 

217 

218 def _impl(): 

219 try: 

220 # Required fields 

221 title = data.get("title") 

222 url = data.get("url") 

223 

224 # Optional fields 

225 content_preview = data.get("content_preview") 

226 source_type = data.get("source_type", "web") 

227 metadata = data.get("metadata", {}) 

228 

229 # Validate required fields 

230 if not title or not url: 

231 return JSONResponse( 

232 { 

233 "status": "error", 

234 "message": "Title and URL are required", 

235 }, 

236 status_code=400, 

237 ) 

238 

239 # Security: Validate URL to prevent SSRF attacks 

240 from ...security.ssrf_validator import validate_url 

241 

242 is_valid = validate_url(url) 

243 if not is_valid: 

244 logger.warning(f"SSRF protection: Rejected URL {url}") 

245 return JSONResponse( 

246 {"status": "error", "message": "Invalid URL"}, 

247 status_code=400, 

248 ) 

249 

250 # Check if the research exists 

251 with get_user_db_session(username) as db_session: 

252 research = ( 

253 db_session.query(ResearchHistory) 

254 .filter_by(id=research_id) 

255 .first() 

256 ) 

257 

258 if not research: 

259 return _research_not_found(research_id) 

260 

261 # Add the resource 

262 resource_id = add_resource( 

263 research_id=research_id, 

264 title=title, 

265 url=url, 

266 content_preview=content_preview, 

267 source_type=source_type, 

268 metadata=metadata, 

269 username=username, 

270 ) 

271 

272 return { 

273 "status": "success", 

274 "message": "Resource added successfully", 

275 "resource_id": resource_id, 

276 } 

277 

278 except Exception: 

279 logger.exception("Error adding resource") 

280 return JSONResponse( 

281 {"status": "error", "message": "Failed to add resource"}, 

282 status_code=500, 

283 ) 

284 

285 return await run_db_sync(_impl) 

286 

287 

288@router.delete("/resources/{research_id}/delete/{resource_id}") 

289def api_delete_resource( 

290 request: Request, 

291 research_id, 

292 # Typed int, matching Flask's <int:resource_id> converter: without it a 

293 # non-numeric segment is passed straight through to the service layer 

294 # instead of being rejected as a 422 by the router. 

295 resource_id: int, 

296 username: Annotated[str, Depends(require_auth)], 

297): 

298 """ 

299 Delete a resource from a research project 

300 """ 

301 try: 

302 # delete_resource() always returns True or raises ValueError("... 

303 # not found") — never False — so the not-found case is handled by 

304 # the except ValueError below, not by checking the return value. 

305 delete_resource(resource_id, username=username) 

306 return { 

307 "status": "success", 

308 "message": "Resource deleted successfully", 

309 } 

310 except ValueError: 

311 return JSONResponse( 

312 {"status": "error", "message": "Resource not found"}, 

313 status_code=404, 

314 ) 

315 except Exception: 

316 logger.exception("Error deleting resource") 

317 return JSONResponse( 

318 { 

319 "status": "error", 

320 "message": "An internal error occurred while deleting the resource.", 

321 }, 

322 status_code=500, 

323 ) 

324 

325 

326def _ollama_base_url_from_config(raw_ollama_base_url): 

327 """Resolve the Ollama base URL from a raw setting value (normalized, with 

328 the default fallback). Single source so every Ollama probe targets the 

329 same URL.""" 

330 return ( 

331 normalize_url(raw_ollama_base_url) 

332 if raw_ollama_base_url 

333 else DEFAULT_OLLAMA_URL 

334 ) 

335 

336 

337def _probe_ollama_tags(base_url, timeout=5): 

338 """Probe Ollama ``/api/tags`` once and classify the outcome. 

339 

340 Single source for the resolve→fetch→new/old-format-parse→error logic that 

341 was previously copy-pasted across the status and model-availability checks 

342 (so "is Ollama up?" can no longer answer differently per caller). Returns 

343 ``(outcome, probe_result)`` where outcome is one of: 

344 

345 - ``"ok"`` → probe_result is the list of model dicts (both API formats handled) 

346 - ``"bad_status"`` → probe_result is the non-200 status code 

347 - ``"invalid_json"`` → probe_result is None (200 but unparseable body) 

348 - ``"connection_error"`` / ``"timeout"`` → probe_result is None 

349 

350 Callers map the outcome onto their own response shape. 

351 """ 

352 try: 

353 response = safe_get( 

354 f"{base_url}/api/tags", 

355 timeout=timeout, 

356 allow_localhost=True, 

357 allow_private_ips=True, 

358 ) 

359 except requests.exceptions.ConnectionError: 

360 return "connection_error", None 

361 except requests.exceptions.Timeout: 

362 return "timeout", None 

363 

364 if response.status_code != 200: 

365 return "bad_status", response.status_code 

366 

367 try: 

368 data = response.json() 

369 except ValueError: 

370 return "invalid_json", None 

371 

372 # New Ollama API nests the list under "models"; the older format is a 

373 # bare list. Mirror the previous inline check exactly (a bare ``"models" 

374 # in data`` membership test, no isinstance guard) so behavior is identical 

375 # to the pre-refactor endpoints — a malformed non-dict/non-list body 

376 # raises here and is handled by each caller's outer except, as before. 

377 if "models" in data: 

378 models = data.get("models", []) 

379 else: 

380 models = data 

381 return "ok", models 

382 

383 

384@router.get("/check/ollama_status") 

385def check_ollama_status( 

386 request: Request, username: Annotated[str, Depends(require_auth)] 

387): 

388 """ 

389 Check if Ollama API is running 

390 """ 

391 try: 

392 # Get the Ollama provider/URL from the user's settings (the Flask 

393 # version read these from app.config["LLM_CONFIG"]). 

394 with get_user_db_session(username) as session: 

395 settings_manager = SettingsManager(session) 

396 provider = settings_manager.get_setting("llm.provider", "ollama") 

397 raw_ollama_base_url = settings_manager.get_setting( 

398 "llm.ollama.url", DEFAULT_OLLAMA_URL 

399 ) 

400 

401 if normalize_provider(provider) != "ollama": 

402 return { 

403 "running": True, 

404 "message": f"Using provider: {provider}, not Ollama", 

405 } 

406 

407 ollama_base_url = _ollama_base_url_from_config(raw_ollama_base_url) 

408 logger.info(f"Checking Ollama status at: {ollama_base_url}") 

409 

410 outcome, probe_result = _probe_ollama_tags(ollama_base_url) 

411 

412 if outcome == "ok": 

413 model_count = len(probe_result) 

414 logger.info(f"Ollama service is running with {model_count} models") 

415 return { 

416 "running": True, 

417 "message": f"Ollama service is running with {model_count} models", 

418 "model_count": model_count, 

419 } 

420 if outcome == "invalid_json": 

421 logger.warning("Ollama returned invalid JSON") 

422 return { 

423 "running": True, 

424 "message": "Ollama service is running but returned invalid data format", 

425 "error_details": "Invalid response format from the service.", 

426 } 

427 if outcome == "bad_status": 

428 logger.warning( 

429 f"Ollama returned non-200 status code: {probe_result}" 

430 ) 

431 return { 

432 "running": False, 

433 "message": f"Ollama service returned status code: {probe_result}", 

434 "status_code": probe_result, 

435 } 

436 if outcome == "connection_error": 

437 logger.warning("Ollama connection error") 

438 return { 

439 "running": False, 

440 "message": "Ollama service is not running or not accessible", 

441 "error_type": "connection_error", 

442 "error_details": "Unable to connect to the service. Please check if the service is running.", 

443 } 

444 # outcome == "timeout" 

445 logger.warning("Ollama request timed out") 

446 return { 

447 "running": False, 

448 "message": "Ollama service request timed out after 5 seconds", 

449 "error_type": "timeout", 

450 "error_details": "Request timed out. The service may be overloaded.", 

451 } 

452 

453 except Exception: 

454 logger.exception("Error checking Ollama status") 

455 return { 

456 "running": False, 

457 "message": "An internal error occurred while checking Ollama status.", 

458 "error_type": "exception", 

459 "error_details": "An internal error occurred.", 

460 } 

461 

462 

463@router.get("/check/ollama_model") 

464def check_ollama_model( 

465 request: Request, username: Annotated[str, Depends(require_auth)] 

466): 

467 """ 

468 Check if the configured Ollama model is available 

469 """ 

470 try: 

471 # Get the Ollama provider/model/URL from the user's settings (the 

472 # Flask version read these from app.config["LLM_CONFIG"]). 

473 with get_user_db_session(username) as session: 

474 settings_manager = SettingsManager(session) 

475 provider = settings_manager.get_setting("llm.provider", "ollama") 

476 configured_model = settings_manager.get_setting("llm.model", "") 

477 raw_ollama_base_url = settings_manager.get_setting( 

478 "llm.ollama.url", DEFAULT_OLLAMA_URL 

479 ) 

480 

481 if normalize_provider(provider) != "ollama": 

482 return { 

483 "available": True, 

484 "message": f"Using provider: {provider}, not Ollama", 

485 "provider": provider, 

486 } 

487 

488 # Get model name from request or fall back to the configured default 

489 model_name = request.query_params.get("model") 

490 if not model_name: 

491 model_name = configured_model 

492 

493 if not model_name: 

494 logger.warning( 

495 "/api/check/ollama_model called with no model name and " 

496 "llm.model is not configured" 

497 ) 

498 return JSONResponse( 

499 { 

500 "available": False, 

501 "model": "", 

502 "message": ( 

503 "Model is required. Pass ?model=<name> in the " 

504 "query string, or configure llm.model in Settings." 

505 ), 

506 "error_type": "model_not_configured", 

507 }, 

508 status_code=400, 

509 ) 

510 

511 # Log which model we're checking for debugging 

512 logger.info(f"Checking availability of Ollama model: {model_name}") 

513 

514 ollama_base_url = _ollama_base_url_from_config(raw_ollama_base_url) 

515 

516 outcome, probe_result = _probe_ollama_tags(ollama_base_url) 

517 

518 if outcome == "bad_status": 

519 logger.warning( 

520 f"Ollama API returned non-200 status: {probe_result}" 

521 ) 

522 return { 

523 "available": False, 

524 "model": model_name, 

525 "message": f"Could not access Ollama service - status code: {probe_result}", 

526 "status_code": probe_result, 

527 } 

528 if outcome == "invalid_json": 

529 logger.warning("Failed to parse Ollama API response") 

530 return { 

531 "available": False, 

532 "model": model_name, 

533 "message": "Invalid response from Ollama API", 

534 "error_type": "json_parse_error", 

535 } 

536 if outcome == "connection_error": 

537 logger.warning("Connection error to Ollama API") 

538 return { 

539 "available": False, 

540 "model": model_name, 

541 "message": "Could not connect to Ollama service", 

542 "error_type": "connection_error", 

543 "error_details": "Unable to connect to the service. Please check if the service is running.", 

544 } 

545 if outcome == "timeout": 

546 logger.warning("Timeout connecting to Ollama API") 

547 return { 

548 "available": False, 

549 "model": model_name, 

550 "message": "Connection to Ollama service timed out", 

551 "error_type": "timeout", 

552 } 

553 

554 # outcome == "ok" 

555 models = probe_result 

556 model_names = [m.get("name", "") for m in models] 

557 logger.debug( 

558 f"Available Ollama models: {', '.join(model_names[:10])}" 

559 + ( 

560 f" and {len(model_names) - 10} more" 

561 if len(model_names) > 10 

562 else "" 

563 ) 

564 ) 

565 

566 # Case-insensitive model name comparison 

567 model_exists = any( 

568 m.get("name", "").lower() == model_name.lower() for m in models 

569 ) 

570 

571 if model_exists: 

572 logger.info(f"Ollama model {model_name} is available") 

573 return { 

574 "available": True, 

575 "model": model_name, 

576 "message": f"Model {model_name} is available", 

577 "all_models": model_names, 

578 } 

579 # Check if models were found at all 

580 if not models: 

581 logger.warning("No models found in Ollama") 

582 message = "No models found in Ollama. Please pull models first." 

583 else: 

584 logger.warning( 

585 f"Model {model_name} not found among {len(models)} available models" 

586 ) 

587 # Don't expose available models for security reasons 

588 message = f"Model {model_name} is not available" 

589 

590 return { 

591 "available": False, 

592 "model": model_name, 

593 "message": message, 

594 # Remove all_models to prevent information disclosure 

595 } 

596 

597 except Exception: 

598 # General exception 

599 logger.exception("Error checking Ollama model") 

600 

601 return { 

602 "available": False, 

603 "model": model_name if "model_name" in locals() else "", 

604 "message": "An internal error occurred while checking the model.", 

605 "error_type": "exception", 

606 "error_details": "An internal error occurred.", 

607 }