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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- 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
|
+
)
|