Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

323 lines
12 KiB
Python

"""
System Status API Router
Manages system status checks and model connection tests
"""
from datetime import datetime
import time
from fastapi import APIRouter
from pydantic import BaseModel
from deeptutor.multi_user.context import get_current_user
from deeptutor.services.config import resolve_search_runtime_config
from deeptutor.services.embedding import get_embedding_client, get_embedding_config
from deeptutor.services.llm import complete as llm_complete
from deeptutor.services.llm import get_llm_config, get_token_limit_kwargs
from deeptutor.services.search import web_search
router = APIRouter()
class TestResponse(BaseModel):
success: bool
message: str
model: str | None = None
response_time_ms: float | None = None
error: str | None = None
@router.get("/runtime-topology")
async def get_runtime_topology():
"""
Describe the current execution topology.
This makes the unified runtime explicit for operators and frontend code:
interactive chat turns should prefer `/api/v1/ws`, while a few routers still
exist as compatibility or isolated subsystem endpoints.
"""
return {
"primary_runtime": {
"transport": "/api/v1/ws",
"manager": "TurnRuntimeManager",
"orchestrator": "ChatOrchestrator",
"session_store": "SQLiteSessionStore",
"capability_entry": "CapabilityRegistry",
"tool_entry": "ToolRegistry",
},
"compatibility_routes": [
{"router": "chat", "mode": "legacy_adapter_target"},
{"router": "solve", "mode": "legacy_adapter_target"},
{"router": "question", "mode": "legacy_specialized"},
{"router": "research", "mode": "legacy_specialized"},
],
"isolated_subsystems": [
{"router": "co_writer", "mode": "independent_subsystem"},
{"router": "plugins_api", "mode": "playground_transport"},
],
}
@router.get("/status")
async def get_system_status():
"""
Get overall system status including backend and model configurations
Returns:
Dictionary containing status of backend, LLM, embeddings, and search
"""
result = {
"backend": {"status": "online", "timestamp": datetime.now().isoformat()},
"llm": {"status": "unknown", "model": None, "testable": True},
"embeddings": {"status": "unknown", "model": None, "testable": True},
"search": {"status": "optional", "provider": None, "testable": True},
}
# Check backend status (this endpoint itself proves backend is online)
result["backend"]["status"] = "online"
# Check LLM configuration
try:
llm_config = get_llm_config()
result["llm"]["model"] = llm_config.model
result["llm"]["status"] = "configured"
except ValueError as e:
result["llm"]["status"] = "not_configured"
result["llm"]["error"] = str(e)
except Exception as e:
result["llm"]["status"] = "error"
result["llm"]["error"] = str(e)
# Check Embeddings configuration
try:
embedding_config = get_embedding_config()
result["embeddings"]["model"] = embedding_config.model
result["embeddings"]["status"] = "configured"
except ValueError as e:
result["embeddings"]["status"] = "not_configured"
result["embeddings"]["error"] = str(e)
except Exception as e:
result["embeddings"]["status"] = "error"
result["embeddings"]["error"] = str(e)
try:
search_config = resolve_search_runtime_config()
if search_config.requested_provider:
result["search"]["provider"] = search_config.provider
if search_config.unsupported_provider:
result["search"]["status"] = "unsupported"
result["search"]["error"] = (
f"{search_config.requested_provider} is deprecated/unsupported. "
"Switch to brave/tavily/jina/searxng/duckduckgo/perplexity."
)
elif search_config.deprecated_provider:
result["search"]["status"] = "deprecated"
result["search"]["error"] = (
f"{search_config.requested_provider} is deprecated. "
"Switch to brave/tavily/jina/searxng/duckduckgo/perplexity."
)
elif search_config.missing_credentials:
result["search"]["status"] = "not_configured"
result["search"]["error"] = (
f"{search_config.requested_provider} requires api_key. "
"Set profile.api_key in Settings > Catalog."
)
elif search_config.provider == "none":
result["search"]["status"] = "disabled"
result["search"]["testable"] = False
else:
result["search"]["status"] = "configured"
if search_config.fallback_reason:
result["search"]["status"] = "fallback"
result["search"]["error"] = search_config.fallback_reason
except Exception as e:
result["search"]["status"] = "error"
result["search"]["error"] = str(e)
# Non-admin users have no need to know which model the admin configured;
# exposing the name leaks operational detail and would let curious users
# fingerprint the deployment. Strip the identifying fields.
if not get_current_user().is_admin:
for section in ("llm", "embeddings"):
result[section].pop("model", None)
result["search"].pop("provider", None)
return result
@router.post("/test/llm", response_model=TestResponse)
async def test_llm_connection():
"""
Test LLM model connection by sending a simple completion request
Returns:
Test result with success status and response time
"""
start_time = time.time()
try:
llm_config = get_llm_config()
model = llm_config.model
base_url = llm_config.base_url.rstrip("/")
# Sanitize Base URL (remove /chat/completions suffix if present)
for suffix in ["/chat/completions", "/completions"]:
if base_url.endswith(suffix):
base_url = base_url[: -len(suffix)]
# Handle API Key (inject dummy if missing for local LLMs)
api_key = llm_config.api_key
if not api_key:
api_key = "sk-no-key-required"
# Send a minimal test request with a prompt that guarantees output
test_prompt = "Say 'OK' to confirm you are working. Do not produce long output."
token_kwargs = get_token_limit_kwargs(model, max_tokens=200)
response = await llm_complete(
model=model,
prompt=test_prompt,
system_prompt="You are a helpful assistant. Respond briefly.",
binding=llm_config.binding,
api_key=api_key,
base_url=base_url,
temperature=0.1,
**token_kwargs,
)
response_time = (time.time() - start_time) * 1000
if response and len(response.strip()) > 0:
return TestResponse(
success=True,
message="LLM connection successful",
model=model,
response_time_ms=round(response_time, 2),
)
return TestResponse(
success=False,
message="LLM connection failed: Empty response",
model=model,
error="Empty response from API",
)
except ValueError as e:
return TestResponse(success=False, message=f"LLM configuration error: {e!s}", error=str(e))
except Exception as e:
response_time = (time.time() - start_time) * 1000
return TestResponse(
success=False,
message=f"LLM connection failed: {e!s}",
response_time_ms=round(response_time, 2),
error=str(e),
)
@router.post("/test/embeddings", response_model=TestResponse)
async def test_embeddings_connection():
"""
Test Embeddings model connection by sending a simple embedding request
Returns:
Test result with success status and response time
"""
start_time = time.time()
try:
embedding_config = get_embedding_config()
embedding_client = get_embedding_client()
model = embedding_config.model
binding = embedding_config.binding
# Probe a tiny batch so "connection OK" also exercises the path RAG
# uses for multi-chunk indexing.
test_texts = ["test", "retrieval batch probe"]
embeddings = await embedding_client.embed(test_texts)
response_time = (time.time() - start_time) * 1000
if (
embeddings is not None
and len(embeddings) == len(test_texts)
and all(len(vector) > 0 for vector in embeddings)
and len({len(vector) for vector in embeddings}) == 1
):
return TestResponse(
success=True,
message=f"Embeddings connection successful ({binding} provider)",
model=model,
response_time_ms=round(response_time, 2),
)
return TestResponse(
success=False,
message="Embeddings connection failed: Invalid response",
model=model,
error="Embedding response must contain one non-empty vector per input",
)
except ValueError as e:
return TestResponse(
success=False, message=f"Embeddings configuration error: {e!s}", error=str(e)
)
except Exception as e:
response_time = (time.time() - start_time) * 1000
return TestResponse(
success=False,
message=f"Embeddings connection failed: {e!s}",
response_time_ms=round(response_time, 2),
error=str(e),
)
@router.post("/test/search", response_model=TestResponse)
async def test_search_connection():
start_time = time.time()
try:
search_config = resolve_search_runtime_config()
if search_config.provider == "none":
return TestResponse(
success=False,
message="Search is disabled",
error="Set a Search provider in Settings > Catalog.",
)
if search_config.unsupported_provider:
return TestResponse(
success=False,
message=(
f"Search provider `{search_config.requested_provider}` is deprecated/unsupported."
),
error="Switch to brave/tavily/jina/searxng/duckduckgo/perplexity",
)
if search_config.missing_credentials:
return TestResponse(
success=False,
message=f"Search provider `{search_config.requested_provider}` missing credentials.",
error="Set profile.api_key in Settings > Catalog.",
)
result = web_search("DeepTutor health check", provider=search_config.provider)
response_time = (time.time() - start_time) * 1000
answer = result.get("answer") or result.get("search_results")
if not answer:
raise ValueError("Search provider returned no content")
return TestResponse(
success=True,
message="Search connection successful",
model=search_config.provider,
response_time_ms=round(response_time, 2),
)
except ValueError as e:
return TestResponse(
success=False, message=f"Search configuration error: {e!s}", error=str(e)
)
except Exception as e:
response_time = (time.time() - start_time) * 1000
return TestResponse(
success=False,
message=f"Search connection check failed: {e!s}",
response_time_ms=round(response_time, 2),
error=str(e),
)