deeptutor-server 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,633 @@
1
+ """
2
+ System Status API Router
3
+ Manages system status checks and model connection tests
4
+ """
5
+
6
+ import asyncio
7
+ from datetime import datetime
8
+ import json
9
+ import time
10
+ from typing import Any, Literal
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
13
+ from pydantic import BaseModel
14
+
15
+ from deeptutor.api.contracts.turn_protocol import (
16
+ MINIMUM_WEB_PROTOCOL_VERSION,
17
+ PROTOCOL_VERSION,
18
+ RuntimeStatus,
19
+ )
20
+ from deeptutor.api.routers.auth import require_admin
21
+ from deeptutor.multi_user.context import get_current_user
22
+ from deeptutor.runtime import memory_probe
23
+ from deeptutor.services.app_update import (
24
+ SYSTEMD_UPDATE_REASON,
25
+ Installation,
26
+ UpdateInProgressError,
27
+ UpdateJob,
28
+ UpdateJobStore,
29
+ UpdateRequestError,
30
+ VersionCheckError,
31
+ VersionCheckResult,
32
+ detect_installation,
33
+ get_version_check_service,
34
+ launcher_available,
35
+ running_under_systemd_service,
36
+ update_store_root,
37
+ )
38
+ from deeptutor.services.config import (
39
+ get_runtime_settings_service,
40
+ resolve_search_runtime_config,
41
+ supported_search_providers_hint,
42
+ )
43
+
44
+
45
+ def get_embedding_client(*args, **kwargs):
46
+ from deeptutor.services.embedding.client import get_embedding_client as resolve
47
+
48
+ return resolve(*args, **kwargs)
49
+
50
+
51
+ def get_embedding_config(*args, **kwargs):
52
+ from deeptutor.services.embedding.config import get_embedding_config as resolve
53
+
54
+ return resolve(*args, **kwargs)
55
+
56
+
57
+ def get_llm_config(*args, **kwargs):
58
+ from deeptutor.services.llm.config import get_llm_config as resolve
59
+
60
+ return resolve(*args, **kwargs)
61
+
62
+
63
+ def get_token_limit_kwargs(*args, **kwargs):
64
+ from deeptutor.services.llm.config import get_token_limit_kwargs as resolve
65
+
66
+ return resolve(*args, **kwargs)
67
+
68
+
69
+ async def llm_complete(*args, **kwargs):
70
+ from deeptutor.services.llm import complete
71
+
72
+ return await complete(*args, **kwargs)
73
+
74
+
75
+ def web_search(*args, **kwargs):
76
+ from deeptutor.services.search import web_search as search
77
+
78
+ return search(*args, **kwargs)
79
+
80
+
81
+ router = APIRouter()
82
+
83
+
84
+ class TestResponse(BaseModel):
85
+ success: bool
86
+ message: str
87
+ model: str | None = None
88
+ response_time_ms: float | None = None
89
+ error: str | None = None
90
+
91
+
92
+ class UpdateSettingsRequest(BaseModel):
93
+ enabled: bool
94
+
95
+
96
+ class ManagedUpdateRequest(BaseModel):
97
+ confirmation: Literal["update-and-restart"]
98
+
99
+
100
+ def get_update_job_store() -> UpdateJobStore:
101
+ return UpdateJobStore(update_store_root())
102
+
103
+
104
+ def get_update_installation() -> Installation:
105
+ return detect_installation()
106
+
107
+
108
+ def get_turn_activity():
109
+ from deeptutor.app.container import get_application_container
110
+
111
+ return get_application_container().runtime_registry.get(
112
+ get_application_container().store_provider.get()
113
+ )
114
+
115
+
116
+ @router.get(
117
+ "/runtime",
118
+ dependencies=[Depends(require_admin)],
119
+ response_model=RuntimeStatus,
120
+ )
121
+ async def get_runtime_status(request: Request) -> RuntimeStatus:
122
+ """Return credential-free coordination and worker diagnostics."""
123
+
124
+ from deeptutor.app.container import get_application_container
125
+
126
+ container = getattr(request.app.state, "application_container", None)
127
+ if container is None:
128
+ container = get_application_container()
129
+ await container.start()
130
+ report = await container.runtime_report()
131
+ return RuntimeStatus.model_validate(
132
+ {
133
+ **report,
134
+ "leader_healthy": bool(report.get("leader_id")),
135
+ "protocol_version": PROTOCOL_VERSION,
136
+ "minimum_web_protocol_version": MINIMUM_WEB_PROTOCOL_VERSION,
137
+ }
138
+ )
139
+
140
+
141
+ def _job_payload(job: UpdateJob | None) -> dict[str, Any] | None:
142
+ if job is None:
143
+ return None
144
+ return {
145
+ "id": job.id,
146
+ "status": job.status,
147
+ "current_version": job.current_version,
148
+ "target_version": job.target_version,
149
+ "created_at": job.created_at,
150
+ "started_at": job.started_at,
151
+ "finished_at": job.finished_at,
152
+ "error": job.error,
153
+ "restart_count": job.restart_count,
154
+ }
155
+
156
+
157
+ def _stored_job(store: UpdateJobStore | None = None) -> UpdateJob | None:
158
+ try:
159
+ return (store or get_update_job_store()).load()
160
+ except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
161
+ return None
162
+
163
+
164
+ def _update_payload(
165
+ *,
166
+ result: VersionCheckResult | None,
167
+ installation: Installation,
168
+ check_enabled: bool,
169
+ check_error: str = "",
170
+ store: UpdateJobStore | None = None,
171
+ ) -> dict[str, Any]:
172
+ release = result.release if result is not None else None
173
+ return {
174
+ "current_version": result.current_version if result else installation.current_version,
175
+ "check_enabled": check_enabled,
176
+ "checked_at": result.checked_at if result else "",
177
+ "cached": result.cached if result else False,
178
+ "check_error": check_error,
179
+ "update_available": result.update_available if result else False,
180
+ "release": (
181
+ {
182
+ "version": release.version,
183
+ "name": release.name,
184
+ "published_at": release.published_at,
185
+ "url": release.url,
186
+ "excerpt": release.excerpt,
187
+ "migration_warning": release.migration_warning,
188
+ }
189
+ if release
190
+ else None
191
+ ),
192
+ "installation": {
193
+ "mode": installation.mode,
194
+ "automatic_update": installation.automatic_update,
195
+ "command": installation.command,
196
+ "reason": installation.reason,
197
+ },
198
+ "launcher_managed": launcher_available(),
199
+ "is_admin": get_current_user().is_admin,
200
+ "job": _job_payload(_stored_job(store)),
201
+ }
202
+
203
+
204
+ async def _checked_update_payload(*, force: bool = False) -> dict[str, Any]:
205
+ settings = get_runtime_settings_service().load_system()
206
+ enabled = bool(settings["version_check_enabled"])
207
+ installation = get_update_installation()
208
+ service = get_version_check_service()
209
+ if not enabled:
210
+ return _update_payload(
211
+ result=service.cached(),
212
+ installation=installation,
213
+ check_enabled=False,
214
+ )
215
+ try:
216
+ result = await service.check(force=force)
217
+ except VersionCheckError as exc:
218
+ if force:
219
+ raise HTTPException(
220
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
221
+ detail=str(exc),
222
+ ) from None
223
+ return _update_payload(
224
+ result=service.cached(),
225
+ installation=installation,
226
+ check_enabled=True,
227
+ check_error=str(exc),
228
+ )
229
+ return _update_payload(
230
+ result=result,
231
+ installation=installation,
232
+ check_enabled=True,
233
+ )
234
+
235
+
236
+ @router.get("/update")
237
+ async def get_update_status() -> dict[str, Any]:
238
+ """Return About-page version state, checking at most once per 24 hours."""
239
+
240
+ return await _checked_update_payload()
241
+
242
+
243
+ @router.post("/update/check", dependencies=[Depends(require_admin)])
244
+ async def check_for_update() -> dict[str, Any]:
245
+ """Perform an explicit administrator-requested release check."""
246
+
247
+ return await _checked_update_payload(force=True)
248
+
249
+
250
+ @router.put("/update/settings", dependencies=[Depends(require_admin)])
251
+ async def update_check_settings(payload: UpdateSettingsRequest) -> dict[str, Any]:
252
+ service = get_runtime_settings_service()
253
+ current = service.load_system(include_process_overrides=False)
254
+ service.save_system({**current, "version_check_enabled": payload.enabled})
255
+ return await _checked_update_payload()
256
+
257
+
258
+ @router.get("/update/job")
259
+ async def get_update_job() -> dict[str, Any] | None:
260
+ return _job_payload(_stored_job())
261
+
262
+
263
+ @router.post(
264
+ "/update",
265
+ status_code=status.HTTP_202_ACCEPTED,
266
+ dependencies=[Depends(require_admin)],
267
+ )
268
+ async def request_managed_update(_request: ManagedUpdateRequest) -> dict[str, Any]:
269
+ """Reserve one safe update for the supervising launcher to apply."""
270
+
271
+ settings = get_runtime_settings_service().load_system()
272
+ if not settings["version_check_enabled"]:
273
+ raise HTTPException(status_code=409, detail="Version checks are disabled")
274
+ if running_under_systemd_service():
275
+ raise HTTPException(status_code=409, detail=SYSTEMD_UPDATE_REASON)
276
+ if not launcher_available():
277
+ raise HTTPException(
278
+ status_code=409,
279
+ detail="Web updates require DeepTutor to be running under `deeptutor start`.",
280
+ )
281
+ installation = get_update_installation()
282
+ if installation.mode != "pypi" or not installation.automatic_update:
283
+ raise HTTPException(
284
+ status_code=409,
285
+ detail=installation.reason or "This installation cannot update itself safely.",
286
+ )
287
+ try:
288
+ result = await get_version_check_service().check()
289
+ except VersionCheckError as exc:
290
+ raise HTTPException(status_code=503, detail=str(exc)) from None
291
+ if not result.update_available:
292
+ raise HTTPException(status_code=409, detail="No newer DeepTutor release is available")
293
+ # Re-check installation evidence immediately before reserving the job. A
294
+ # deployment changing underneath this request fails closed.
295
+ confirmed = get_update_installation()
296
+ if confirmed.mode != "pypi" or not confirmed.automatic_update:
297
+ raise HTTPException(
298
+ status_code=409, detail="The installation changed during the update check"
299
+ )
300
+ store = get_update_job_store()
301
+ try:
302
+ job = await get_turn_activity().reserve_managed_update(
303
+ lambda: store.create(
304
+ current_version=result.current_version,
305
+ target_version=result.release.version,
306
+ )
307
+ )
308
+ except (UpdateInProgressError, UpdateRequestError, ValueError) as exc:
309
+ raise HTTPException(status_code=409, detail=str(exc)) from None
310
+ if job is None:
311
+ raise HTTPException(
312
+ status_code=409,
313
+ detail="Finish the active conversation before updating DeepTutor.",
314
+ )
315
+ return _job_payload(job) or {}
316
+
317
+
318
+ @router.get("/status")
319
+ async def get_system_status():
320
+ """
321
+ Get overall system status including backend and model configurations
322
+
323
+ Returns:
324
+ Dictionary containing status of backend, LLM, embeddings, and search
325
+ """
326
+ result = {
327
+ "backend": {"status": "online", "timestamp": datetime.now().isoformat()},
328
+ "llm": {"status": "unknown", "model": None, "testable": True},
329
+ "embeddings": {"status": "unknown", "model": None, "testable": True},
330
+ "search": {"status": "optional", "provider": None, "testable": True},
331
+ }
332
+
333
+ # Check backend status (this endpoint itself proves backend is online)
334
+ result["backend"]["status"] = "online"
335
+
336
+ # Check LLM configuration
337
+ try:
338
+ llm_config = get_llm_config()
339
+ result["llm"]["model"] = llm_config.model
340
+ result["llm"]["status"] = "configured"
341
+ except ValueError as e:
342
+ result["llm"]["status"] = "not_configured"
343
+ result["llm"]["error"] = str(e)
344
+ except Exception as e:
345
+ result["llm"]["status"] = "error"
346
+ result["llm"]["error"] = str(e)
347
+
348
+ # Check Embeddings configuration
349
+ try:
350
+ embedding_config = get_embedding_config()
351
+ result["embeddings"]["model"] = embedding_config.model
352
+ result["embeddings"]["status"] = "configured"
353
+ except ValueError as e:
354
+ result["embeddings"]["status"] = "not_configured"
355
+ result["embeddings"]["error"] = str(e)
356
+ except Exception as e:
357
+ result["embeddings"]["status"] = "error"
358
+ result["embeddings"]["error"] = str(e)
359
+
360
+ try:
361
+ search_config = resolve_search_runtime_config()
362
+ if search_config.requested_provider:
363
+ result["search"]["provider"] = search_config.provider
364
+ if search_config.unsupported_provider:
365
+ result["search"]["status"] = "unsupported"
366
+ result["search"]["error"] = (
367
+ f"{search_config.requested_provider} is deprecated/unsupported. "
368
+ f"Switch to {supported_search_providers_hint()}."
369
+ )
370
+ elif search_config.deprecated_provider:
371
+ result["search"]["status"] = "deprecated"
372
+ result["search"]["error"] = (
373
+ f"{search_config.requested_provider} is deprecated. "
374
+ f"Switch to {supported_search_providers_hint()}."
375
+ )
376
+ elif search_config.missing_credentials:
377
+ result["search"]["status"] = "not_configured"
378
+ result["search"]["error"] = (
379
+ f"{search_config.requested_provider} requires api_key. "
380
+ "Set profile.api_key in Settings > Catalog."
381
+ )
382
+ elif search_config.provider == "none":
383
+ result["search"]["status"] = "disabled"
384
+ result["search"]["testable"] = False
385
+ else:
386
+ result["search"]["status"] = "configured"
387
+ if search_config.fallback_reason:
388
+ result["search"]["status"] = "fallback"
389
+ result["search"]["error"] = search_config.fallback_reason
390
+ except Exception as e:
391
+ result["search"]["status"] = "error"
392
+ result["search"]["error"] = str(e)
393
+
394
+ # Non-admin users have no need to know which model the admin configured;
395
+ # exposing the name leaks operational detail and would let curious users
396
+ # fingerprint the deployment. Strip the identifying fields.
397
+ if not get_current_user().is_admin:
398
+ for section in ("llm", "embeddings"):
399
+ result[section].pop("model", None)
400
+ result["search"].pop("provider", None)
401
+
402
+ return result
403
+
404
+
405
+ @router.get("/memory")
406
+ async def get_memory_usage():
407
+ """Resident memory of the running DeepTutor process tree.
408
+
409
+ Deliberately separate from ``/status``: that snapshot resolves the LLM,
410
+ embedding and search configs and is fetched once per settings mount, while
411
+ this one is cheap enough for the status strip to poll.
412
+
413
+ Admin-only, for the same reason ``/status`` strips model names from
414
+ non-admins — process composition and host memory are operational detail a
415
+ tenant has no need for.
416
+ """
417
+ if not get_current_user().is_admin:
418
+ return {"available": False}
419
+
420
+ snapshot = await asyncio.to_thread(memory_probe.capture)
421
+ if not snapshot.processes:
422
+ return {"available": False}
423
+
424
+ # Fold the tree into one row per role, largest first, so the tooltip stays
425
+ # readable when capabilities have spawned a dozen short-lived sandboxes.
426
+ grouped: dict[str, dict[str, int]] = {}
427
+ for proc in snapshot.processes:
428
+ row = grouped.setdefault(proc.label, {"count": 0, "rss_bytes": 0})
429
+ row["count"] += 1
430
+ row["rss_bytes"] += proc.rss_bytes
431
+ ranked = sorted(grouped.items(), key=lambda item: item[1]["rss_bytes"], reverse=True)
432
+
433
+ processes = [
434
+ {"label": label, "count": row["count"], "rss_bytes": row["rss_bytes"]}
435
+ for label, row in ranked[: memory_probe.MAX_REPORTED_PROCESSES]
436
+ ]
437
+ overflow = ranked[memory_probe.MAX_REPORTED_PROCESSES :]
438
+ if overflow:
439
+ processes.append(
440
+ {
441
+ "label": "other",
442
+ "count": sum(row["count"] for _label, row in overflow),
443
+ "rss_bytes": sum(row["rss_bytes"] for _label, row in overflow),
444
+ }
445
+ )
446
+
447
+ return {
448
+ "available": True,
449
+ "total_rss_bytes": snapshot.total_rss_bytes,
450
+ "limit_bytes": snapshot.limit_bytes,
451
+ "available_bytes": snapshot.available_bytes,
452
+ "limit_source": snapshot.limit_source,
453
+ "usage_ratio": snapshot.usage_ratio,
454
+ "partial": snapshot.partial,
455
+ "processes": processes,
456
+ }
457
+
458
+
459
+ @router.post("/test/llm", response_model=TestResponse)
460
+ async def test_llm_connection():
461
+ """
462
+ Test LLM model connection by sending a simple completion request
463
+
464
+ Returns:
465
+ Test result with success status and response time
466
+ """
467
+ start_time = time.time()
468
+
469
+ try:
470
+ llm_config = get_llm_config()
471
+ model = llm_config.model
472
+ base_url = llm_config.base_url.rstrip("/")
473
+
474
+ # Sanitize Base URL (remove /chat/completions suffix if present)
475
+ for suffix in ["/chat/completions", "/completions"]:
476
+ if base_url.endswith(suffix):
477
+ base_url = base_url[: -len(suffix)]
478
+
479
+ # Handle API Key (inject dummy if missing for local LLMs)
480
+ api_key = llm_config.api_key
481
+ if not api_key:
482
+ api_key = "sk-no-key-required"
483
+
484
+ # Send a minimal test request with a prompt that guarantees output
485
+ test_prompt = "Say 'OK' to confirm you are working. Do not produce long output."
486
+ token_kwargs = get_token_limit_kwargs(model, max_tokens=200)
487
+
488
+ response = await llm_complete(
489
+ model=model,
490
+ prompt=test_prompt,
491
+ system_prompt="You are a helpful assistant. Respond briefly.",
492
+ binding=llm_config.binding,
493
+ api_key=api_key,
494
+ base_url=base_url,
495
+ temperature=0.1,
496
+ **token_kwargs,
497
+ )
498
+
499
+ response_time = (time.time() - start_time) * 1000
500
+
501
+ if response and len(response.strip()) > 0:
502
+ return TestResponse(
503
+ success=True,
504
+ message="LLM connection successful",
505
+ model=model,
506
+ response_time_ms=round(response_time, 2),
507
+ )
508
+ return TestResponse(
509
+ success=False,
510
+ message="LLM connection failed: Empty response",
511
+ model=model,
512
+ error="Empty response from API",
513
+ )
514
+
515
+ except ValueError as e:
516
+ return TestResponse(success=False, message=f"LLM configuration error: {e!s}", error=str(e))
517
+ except Exception as e:
518
+ response_time = (time.time() - start_time) * 1000
519
+ return TestResponse(
520
+ success=False,
521
+ message=f"LLM connection failed: {e!s}",
522
+ response_time_ms=round(response_time, 2),
523
+ error=str(e),
524
+ )
525
+
526
+
527
+ @router.post("/test/embeddings", response_model=TestResponse)
528
+ async def test_embeddings_connection():
529
+ """
530
+ Test Embeddings model connection by sending a simple embedding request
531
+
532
+ Returns:
533
+ Test result with success status and response time
534
+ """
535
+ start_time = time.time()
536
+
537
+ try:
538
+ embedding_config = get_embedding_config()
539
+ embedding_client = get_embedding_client()
540
+
541
+ model = embedding_config.model
542
+ binding = embedding_config.binding
543
+
544
+ # Probe a tiny batch so "connection OK" also exercises the path RAG
545
+ # uses for multi-chunk indexing.
546
+ test_texts = ["test", "retrieval batch probe"]
547
+ embeddings = await embedding_client.embed(test_texts)
548
+
549
+ response_time = (time.time() - start_time) * 1000
550
+
551
+ if (
552
+ embeddings is not None
553
+ and len(embeddings) == len(test_texts)
554
+ and all(len(vector) > 0 for vector in embeddings)
555
+ and len({len(vector) for vector in embeddings}) == 1
556
+ ):
557
+ return TestResponse(
558
+ success=True,
559
+ message=f"Embeddings connection successful ({binding} provider)",
560
+ model=model,
561
+ response_time_ms=round(response_time, 2),
562
+ )
563
+ return TestResponse(
564
+ success=False,
565
+ message="Embeddings connection failed: Invalid response",
566
+ model=model,
567
+ error="Embedding response must contain one non-empty vector per input",
568
+ )
569
+
570
+ except ValueError as e:
571
+ return TestResponse(
572
+ success=False, message=f"Embeddings configuration error: {e!s}", error=str(e)
573
+ )
574
+ except Exception as e:
575
+ response_time = (time.time() - start_time) * 1000
576
+ return TestResponse(
577
+ success=False,
578
+ message=f"Embeddings connection failed: {e!s}",
579
+ response_time_ms=round(response_time, 2),
580
+ error=str(e),
581
+ )
582
+
583
+
584
+ @router.post("/test/search", response_model=TestResponse)
585
+ async def test_search_connection():
586
+ start_time = time.time()
587
+
588
+ try:
589
+ search_config = resolve_search_runtime_config()
590
+ if search_config.provider == "none":
591
+ return TestResponse(
592
+ success=False,
593
+ message="Search is disabled",
594
+ error="Set a Search provider in Settings > Catalog.",
595
+ )
596
+ if search_config.unsupported_provider:
597
+ return TestResponse(
598
+ success=False,
599
+ message=(
600
+ f"Search provider `{search_config.requested_provider}` is deprecated/unsupported."
601
+ ),
602
+ error=f"Switch to {supported_search_providers_hint()}",
603
+ )
604
+ if search_config.missing_credentials:
605
+ return TestResponse(
606
+ success=False,
607
+ message=f"Search provider `{search_config.requested_provider}` missing credentials.",
608
+ error="Set profile.api_key in Settings > Catalog.",
609
+ )
610
+ result = web_search("DeepTutor health check", provider=search_config.provider)
611
+ response_time = (time.time() - start_time) * 1000
612
+ answer = result.get("answer") or result.get("search_results")
613
+ if not answer:
614
+ raise ValueError("Search provider returned no content")
615
+ return TestResponse(
616
+ success=True,
617
+ message="Search connection successful",
618
+ model=search_config.provider,
619
+ response_time_ms=round(response_time, 2),
620
+ )
621
+
622
+ except ValueError as e:
623
+ return TestResponse(
624
+ success=False, message=f"Search configuration error: {e!s}", error=str(e)
625
+ )
626
+ except Exception as e:
627
+ response_time = (time.time() - start_time) * 1000
628
+ return TestResponse(
629
+ success=False,
630
+ message=f"Search connection check failed: {e!s}",
631
+ response_time_ms=round(response_time, 2),
632
+ error=str(e),
633
+ )