Coverage for src/local_deep_research/web/routes/api_routes.py: 98%

204 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1import requests 

2from flask import ( 

3 Blueprint, 

4 current_app, 

5 jsonify, 

6 request, 

7) 

8from loguru import logger 

9 

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

11from ...database.session_context import get_user_db_session 

12from ...config.constants import DEFAULT_OLLAMA_URL 

13from ...constants import ResearchStatus 

14from ...utilities.url_utils import normalize_url 

15from ...security.decorators import require_json_body 

16from ..auth.decorators import login_required 

17from ..routes.research_routes 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 ...llm.providers.base import normalize_provider 

28from ...security import safe_get, strip_settings_snapshot 

29 

30# Create blueprint 

31api_bp = Blueprint("api", __name__) 

32 

33# NOTE: Routes use session["username"] (not .get()) intentionally. 

34# @login_required guarantees the key exists; direct access fails fast 

35# if the decorator is ever removed. 

36 

37 

38@api_bp.route("/settings/current-config", methods=["GET"]) 

39@login_required 

40def get_current_config(): 

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

42 try: 

43 with get_user_db_session() as session: 

44 settings_manager = SettingsManager(session) 

45 config = { 

46 "provider": settings_manager.get_setting( 

47 "llm.provider", "Not configured" 

48 ), 

49 "model": settings_manager.get_setting( 

50 "llm.model", "Not configured" 

51 ), 

52 "search_tool": settings_manager.get_setting( 

53 "search.tool", "searxng" 

54 ), 

55 "iterations": settings_manager.get_setting( 

56 "search.iterations", 8 

57 ), 

58 "questions_per_iteration": settings_manager.get_setting( 

59 "search.questions_per_iteration", 5 

60 ), 

61 "search_strategy": settings_manager.get_setting( 

62 "search.search_strategy", "focused_iteration" 

63 ), 

64 } 

65 

66 return jsonify({"success": True, "config": config}) 

67 

68 except Exception: 

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

70 return jsonify( 

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

72 ), 500 

73 

74 

75# API Routes 

76@api_bp.route("/start", methods=["POST"]) 

77@login_required 

78def api_start_research(): 

79 """ 

80 Start a new research process. 

81 

82 Delegates to the full-featured start_research() in research_routes, 

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

84 the research thread. 

85 """ 

86 from ..routes.research_routes import start_research 

87 

88 return start_research() 

89 

90 

91@api_bp.route("/status/<string:research_id>", methods=["GET"]) 

92@login_required 

93def api_research_status(research_id): 

94 """ 

95 Get the status of a research process 

96 """ 

97 try: 

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

99 

100 with get_user_db_session() as db_session: 

101 research = ( 

102 db_session.query(ResearchHistory) 

103 .filter_by(id=research_id) 

104 .first() 

105 ) 

106 

107 if research is None: 

108 return _research_not_found(research_id) 

109 

110 # Extract attributes while session is active 

111 # to avoid DetachedInstanceError after the with block exits 

112 result = { 

113 "status": research.status, 

114 "progress": research.progress, 

115 "completed_at": research.completed_at, 

116 "report_path": research.report_path, 

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

118 } 

119 

120 # Include queue position for queued research 

121 if research.status == ResearchStatus.QUEUED: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 queued = ( 

123 db_session.query(QueuedResearch) 

124 .filter_by(research_id=research_id) 

125 .first() 

126 ) 

127 if queued: 

128 result["queue_position"] = queued.position 

129 

130 return jsonify(result) 

131 except Exception: 

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

133 return jsonify( 

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

135 ), 500 

136 

137 

138@api_bp.route("/terminate/<string:research_id>", methods=["POST"]) 

139@login_required 

140def api_terminate_research(research_id): 

141 """ 

142 Terminate a research process 

143 """ 

144 try: 

145 from flask import session 

146 

147 username = session["username"] 

148 result = cancel_research(research_id, username) 

149 if result: 

150 return jsonify( 

151 { 

152 "status": "success", 

153 "message": "Research terminated", 

154 "result": result, 

155 } 

156 ) 

157 return jsonify( 

158 { 

159 "status": "success", 

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

161 "result": result, 

162 } 

163 ) 

164 except Exception: 

165 logger.exception("Error terminating research") 

166 return ( 

167 jsonify({"status": "error", "message": "Failed to stop research."}), 

168 500, 

169 ) 

170 

171 

172@api_bp.route("/resources/<string:research_id>", methods=["GET"]) 

173@login_required 

174def api_get_resources(research_id): 

175 """ 

176 Get resources for a specific research 

177 """ 

178 try: 

179 resources = get_resources_for_research(research_id) 

180 return jsonify({"status": "success", "resources": resources}) 

181 except Exception: 

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

183 return jsonify( 

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

185 ), 500 

186 

187 

188@api_bp.route("/resources/<string:research_id>", methods=["POST"]) 

189@login_required 

190@require_json_body(error_format="status") 

191def api_add_resource(research_id): 

192 """ 

193 Add a new resource to a research project 

194 """ 

195 try: 

196 data = request.json 

197 # Required fields 

198 title = data.get("title") 

199 url = data.get("url") 

200 

201 # Optional fields 

202 content_preview = data.get("content_preview") 

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

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

205 

206 # Validate required fields 

207 if not title or not url: 

208 return ( 

209 jsonify( 

210 {"status": "error", "message": "Title and URL are required"} 

211 ), 

212 400, 

213 ) 

214 

215 # Security: Validate URL to prevent SSRF attacks 

216 from ...security.ssrf_validator import validate_url 

217 

218 is_valid = validate_url(url) 

219 if not is_valid: 

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

221 return ( 

222 jsonify({"status": "error", "message": "Invalid URL"}), 

223 400, 

224 ) 

225 

226 # Check if the research exists 

227 with get_user_db_session() as db_session: 

228 research = ( 

229 db_session.query(ResearchHistory) 

230 .filter_by(id=research_id) 

231 .first() 

232 ) 

233 

234 if not research: 

235 return _research_not_found(research_id) 

236 

237 # Add the resource 

238 resource_id = add_resource( 

239 research_id=research_id, 

240 title=title, 

241 url=url, 

242 content_preview=content_preview, 

243 source_type=source_type, 

244 metadata=metadata, 

245 ) 

246 

247 return jsonify( 

248 { 

249 "status": "success", 

250 "message": "Resource added successfully", 

251 "resource_id": resource_id, 

252 } 

253 ) 

254 except Exception: 

255 logger.exception("Error adding resource") 

256 return jsonify( 

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

258 ), 500 

259 

260 

261@api_bp.route( 

262 "/resources/<string:research_id>/delete/<int:resource_id>", 

263 methods=["DELETE"], 

264) 

265@login_required 

266def api_delete_resource(research_id, resource_id): 

267 """ 

268 Delete a resource from a research project 

269 """ 

270 try: 

271 # Delete the resource 

272 success = delete_resource(resource_id) 

273 

274 if success: 

275 return jsonify( 

276 { 

277 "status": "success", 

278 "message": "Resource deleted successfully", 

279 } 

280 ) 

281 return jsonify( 

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

283 ), 404 

284 except Exception: 

285 logger.exception("Error deleting resource") 

286 return jsonify( 

287 { 

288 "status": "error", 

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

290 } 

291 ), 500 

292 

293 

294def _ollama_base_url_from_config(llm_config): 

295 """Resolve the Ollama base URL from the LLM config (normalized, with the 

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

297 URL.""" 

298 raw = llm_config.get("ollama_base_url", DEFAULT_OLLAMA_URL) 

299 return normalize_url(raw) if raw else DEFAULT_OLLAMA_URL 

300 

301 

302def _probe_ollama_tags(base_url, timeout=5): 

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

304 

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

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

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

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

309 

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

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

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

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

314 

315 Callers map the outcome onto their own response shape. 

316 """ 

317 try: 

318 response = safe_get( 

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

320 timeout=timeout, 

321 allow_localhost=True, 

322 allow_private_ips=True, 

323 ) 

324 except requests.exceptions.ConnectionError: 

325 return "connection_error", None 

326 except requests.exceptions.Timeout: 

327 return "timeout", None 

328 

329 if response.status_code != 200: 

330 return "bad_status", response.status_code 

331 

332 try: 

333 data = response.json() 

334 except ValueError: 

335 return "invalid_json", None 

336 

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

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

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

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

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

342 if "models" in data: 

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

344 else: 

345 models = data 

346 return "ok", models 

347 

348 

349@api_bp.route("/check/ollama_status", methods=["GET"]) 

350@login_required 

351def check_ollama_status(): 

352 """ 

353 Check if Ollama API is running 

354 """ 

355 try: 

356 # Get Ollama URL from config 

357 llm_config = current_app.config.get("LLM_CONFIG", {}) 

358 provider = normalize_provider(llm_config.get("provider", "ollama")) 

359 

360 if provider != "ollama": 

361 return jsonify( 

362 { 

363 "running": True, 

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

365 } 

366 ) 

367 

368 ollama_base_url = _ollama_base_url_from_config(llm_config) 

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

370 

371 outcome, probe_result = _probe_ollama_tags(ollama_base_url) 

372 

373 if outcome == "ok": 

374 model_count = len(probe_result) 

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

376 return jsonify( 

377 { 

378 "running": True, 

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

380 "model_count": model_count, 

381 } 

382 ) 

383 if outcome == "invalid_json": 

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

385 return jsonify( 

386 { 

387 "running": True, 

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

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

390 } 

391 ) 

392 if outcome == "bad_status": 

393 logger.warning( 

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

395 ) 

396 return jsonify( 

397 { 

398 "running": False, 

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

400 "status_code": probe_result, 

401 } 

402 ) 

403 if outcome == "connection_error": 

404 logger.warning("Ollama connection error") 

405 return jsonify( 

406 { 

407 "running": False, 

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

409 "error_type": "connection_error", 

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

411 } 

412 ) 

413 # outcome == "timeout" 

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

415 return jsonify( 

416 { 

417 "running": False, 

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

419 "error_type": "timeout", 

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

421 } 

422 ) 

423 

424 except Exception: 

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

426 return jsonify( 

427 { 

428 "running": False, 

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

430 "error_type": "exception", 

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

432 } 

433 ) 

434 

435 

436@api_bp.route("/check/ollama_model", methods=["GET"]) 

437@login_required 

438def check_ollama_model(): 

439 """ 

440 Check if the configured Ollama model is available 

441 """ 

442 try: 

443 # Get Ollama configuration 

444 llm_config = current_app.config.get("LLM_CONFIG", {}) 

445 provider = normalize_provider(llm_config.get("provider", "ollama")) 

446 

447 if provider != "ollama": 

448 return jsonify( 

449 { 

450 "available": True, 

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

452 "provider": provider, 

453 } 

454 ) 

455 

456 # Get model name from request or use config default 

457 model_name = request.args.get("model") 

458 if not model_name: 

459 model_name = llm_config.get("model", "") 

460 

461 if not model_name: 

462 logger.warning( 

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

464 "llm.model is not configured" 

465 ) 

466 return jsonify( 

467 { 

468 "available": False, 

469 "model": "", 

470 "message": ( 

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

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

473 ), 

474 "error_type": "model_not_configured", 

475 } 

476 ), 400 

477 

478 # Log which model we're checking for debugging 

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

480 

481 ollama_base_url = _ollama_base_url_from_config(llm_config) 

482 

483 outcome, probe_result = _probe_ollama_tags(ollama_base_url) 

484 

485 if outcome == "bad_status": 

486 logger.warning( 

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

488 ) 

489 return jsonify( 

490 { 

491 "available": False, 

492 "model": model_name, 

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

494 "status_code": probe_result, 

495 } 

496 ) 

497 if outcome == "invalid_json": 

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

499 return jsonify( 

500 { 

501 "available": False, 

502 "model": model_name, 

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

504 "error_type": "json_parse_error", 

505 } 

506 ) 

507 if outcome == "connection_error": 

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

509 return jsonify( 

510 { 

511 "available": False, 

512 "model": model_name, 

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

514 "error_type": "connection_error", 

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

516 } 

517 ) 

518 if outcome == "timeout": 

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

520 return jsonify( 

521 { 

522 "available": False, 

523 "model": model_name, 

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

525 "error_type": "timeout", 

526 } 

527 ) 

528 

529 # outcome == "ok" 

530 models = probe_result 

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

532 logger.debug( 

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

534 + ( 

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

536 if len(model_names) > 10 

537 else "" 

538 ) 

539 ) 

540 

541 # Case-insensitive model name comparison 

542 model_exists = any( 

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

544 ) 

545 

546 if model_exists: 

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

548 return jsonify( 

549 { 

550 "available": True, 

551 "model": model_name, 

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

553 "all_models": model_names, 

554 } 

555 ) 

556 # Check if models were found at all 

557 if not models: 

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

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

560 else: 

561 logger.warning( 

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

563 ) 

564 # Don't expose available models for security reasons 

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

566 

567 return jsonify( 

568 { 

569 "available": False, 

570 "model": model_name, 

571 "message": message, 

572 # Remove all_models to prevent information disclosure 

573 } 

574 ) 

575 

576 except Exception: 

577 # General exception 

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

579 

580 return jsonify( 

581 { 

582 "available": False, 

583 "model": ( 

584 model_name 

585 if "model_name" in locals() 

586 else llm_config.get("model", "") 

587 ), 

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

589 "error_type": "exception", 

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

591 } 

592 )