"""Define API routes for status.""" import asyncio import time from typing import Any from fastapi import APIRouter, Request from app.config.settings import settings from app.domain.retrieval import RetrievedChunk from app.mcp.server import get_mcp_status from app.services.llm.llm_factory import get_llm_client, get_llm_factory from app.shared.bootstrap import ( get_bm25_retriever, get_binary_store, get_conversation_store, get_document_query_service, get_embedding_provider, get_reranker, get_vector_index, ) from app.shared.model_usage_tracker import get_model_usage_tracker router = APIRouter(prefix="/status", tags=["系统状态"]) # --------------------------------------------------------------------------- # Simple TTL cache for /stats (avoids O(N) doc scan on every request) # --------------------------------------------------------------------------- _stats_cache: dict[str, Any] = {} _stats_cache_time: float = 0.0 _STATS_TTL_SECONDS: float = 10.0 # --------------------------------------------------------------------------- # AI model roles surfaced on the Status page (Task: System Status AI models) # --------------------------------------------------------------------------- _MODEL_ROLES: dict[str, str] = { "main_llm": "主问答 LLM", "hyde_llm": "HyDE 查询增强", "embedding": "Embedding", "reranker": "Reranker", } @router.get("/stats") async def get_stats(): """Return document statistics (cached for 10 s).""" global _stats_cache, _stats_cache_time now = time.time() if _stats_cache and (now - _stats_cache_time) < _STATS_TTL_SECONDS: return _stats_cache documents = get_document_query_service().list_documents() indexed = sum(1 for d in documents if d.status.value == "indexed") failed = sum(1 for d in documents if d.status.value == "failed") _stats_cache = { "documents_total": len(documents), "documents_indexed": indexed, "documents_failed": failed, "chunks_total": sum(d.chunk_count for d in documents), } _stats_cache_time = now return _stats_cache @router.get("/config") async def get_config(): """Return system configuration.""" return { "embedding_model": settings.embedding_model, "embedding_dim": settings.embedding_dim, "embedding_base_url": settings.embedding_base_url, "milvus_collection": settings.milvus_collection, "parser_backend": settings.parser_backend, "chunk_backend": settings.chunk_backend, "artifact_prefix": settings.document_parse_artifact_prefix, "parser_failure_mode": settings.parser_failure_mode, "llm_provider": settings.llm_provider, "llm_model": settings.llm_model, "document_metadata_path": settings.document_metadata_path, } @router.get("/milvus/health") async def milvus_health(): """Return Milvus health (kept for backwards compat).""" return get_vector_index().health() @router.get("/health") async def get_health(): """Return aggregate health of all backend services.""" # --- Milvus --- try: milvus_info = get_vector_index().health() milvus_status = "ok" if milvus_info.get("connected") else "error" except Exception as exc: # noqa: BLE001 milvus_info = {} milvus_status = "error" milvus_info["error"] = str(exc) # --- MinIO --- try: minio_connected = get_binary_store().client.connected minio_status = "ok" if minio_connected else "error" except Exception: # noqa: BLE001 minio_status = "error" minio_connected = False # --- BM25 --- bm25 = get_bm25_retriever() # --- Sessions --- try: session_count = len(get_conversation_store().list_sessions()) except Exception: # noqa: BLE001 session_count = 0 return { "milvus": {"status": milvus_status, **milvus_info}, "minio": {"status": minio_status, "connected": minio_connected}, "bm25": {"available": bm25 is not None}, "reranker": { "enabled": settings.reranker_enabled, "model": settings.reranker_model if settings.reranker_enabled else None, }, "sessions": { "active": session_count, "max": settings.session_max_sessions, }, } def _normalize_llm_provider(raw_provider: str) -> str: """Normalize a raw LLM_PROVIDER/HYDE_LLM_PROVIDER settings string to the canonical LLMProvider enum value, the SAME way LLMFactory.create() does. TrackedLLMClient.chat() (tracked_client.py) always records usage under `self._inner.config.provider.value` — the NORMALIZED enum value produced by LLMFactory._parse_provider() — never the raw string a caller passed to get_llm_client(). Reusing that same normalization here (instead of duplicating the alias table) guarantees the tracker key this route reads always agrees with the key TrackedLLMClient wrote, even when the raw settings value is a non-canonical alias (e.g. "deepseek-v3") or different casing. Falls back to the raw string, unchanged, if it does not match any known provider/alias, so this passive status endpoint still renders (as "never_called") instead of raising on a misconfigured provider string. """ try: return get_llm_factory()._parse_provider(raw_provider).value except ValueError: return raw_provider def _resolve_role_provider_model(role: str) -> tuple[str, str]: """Return the (provider, model) pair currently configured for one AI model role. For "hyde_llm" this mirrors the exact fallback logic already used in hyde_expander.py (settings.hyde_llm_provider or settings.llm_provider, same for model) so tracker lookups here always match what TrackedLLMClient recorded when HyDE actually ran. """ if role == "main_llm": return _normalize_llm_provider(settings.llm_provider), settings.llm_model if role == "hyde_llm": return ( _normalize_llm_provider(settings.hyde_llm_provider or settings.llm_provider), settings.hyde_llm_model or settings.llm_model, ) if role == "embedding": return "embedding", settings.embedding_model if role == "reranker": return "reranker", settings.reranker_model raise ValueError(f"unknown model role: {role}") # pragma: no cover - internal roles are fixed def _build_model_status(role: str) -> dict[str, Any]: """Build one /status/models row for the given role from tracker data + live settings.""" provider, model = _resolve_role_provider_model(role) entry = get_model_usage_tracker().get(provider, model) main_provider, main_model = _resolve_role_provider_model("main_llm") shares_usage_with = ( "main_llm" if role != "main_llm" and (provider, model) == (main_provider, main_model) else None ) enabled = True status = entry.status if entry else "never_called" if role == "reranker": enabled = settings.reranker_enabled if not enabled: # Config always wins: report "disabled" even if the reranker was # enabled and called successfully earlier in this process's life. status = "disabled" elif role == "hyde_llm": enabled = settings.hyde_enabled if not enabled: # Same "config always wins" override as the reranker branch above: # report "disabled" even if HyDE ran successfully before being # turned off in settings during this process's life. status = "disabled" return { "role": role, "role_label": _MODEL_ROLES[role], "provider": provider, "model": model, "enabled": enabled, "status": status, "total_tokens": entry.total_tokens if entry else 0, "call_count_ok": entry.call_count_ok if entry else 0, "call_count_error": entry.call_count_error if entry else 0, "last_called_at": entry.last_called_at.isoformat() if entry and entry.last_called_at else None, "last_latency_ms": entry.last_latency_ms if entry else None, "last_error": entry.last_error if entry else None, "shares_usage_with": shares_usage_with, } @router.get("/models") async def get_model_statuses(): """Return connection status + cumulative token usage for all 4 tracked AI model roles. Passive: reads tracker state + settings only, makes no outbound network calls. """ return {"models": [_build_model_status(role) for role in _MODEL_ROLES]} async def _ping_main_or_hyde(role: str) -> None: """Send one minimal chat completion to the LLM configured for `role`. Skipped entirely for "hyde_llm" when settings.hyde_enabled is False, mirroring _ping_reranker()'s disabled-skip pattern: when HyDE is turned off (or reuses the main LLM, the default), issuing this ping would just be a redundant duplicate chat call against the same model for no benefit. "main_llm" is always pinged regardless of this check. """ if role == "hyde_llm" and not settings.hyde_enabled: return provider, model = _resolve_role_provider_model(role) try: client = get_llm_client(provider=provider, model=model) except Exception as exc: # noqa: BLE001 - record, then re-raise so gather() still isolates this ping # get_llm_client() can fail before any TrackedLLMClient exists to # record the outcome itself (e.g. missing API key, unsupported # provider string), so record the failure here directly, otherwise it # would be invisible on the /status/models page afterward. get_model_usage_tracker().record(provider=provider, model=model, success=False, error=str(exc)) raise await asyncio.to_thread(client.chat, [{"role": "user", "content": "ping"}], max_tokens=1) async def _ping_embedding() -> None: """Send one minimal embedding request.""" await asyncio.to_thread(get_embedding_provider().embed_query, "ping") async def _ping_reranker() -> None: """Send one minimal rerank request, only when the reranker is enabled.""" reranker = get_reranker() if reranker is None: return # Minimal single-chunk probe — real content doesn't matter, only round-trip success. placeholder = RetrievedChunk(chunk_id="ping", doc_id="ping", doc_title="ping", text="ping", score=0.0) await asyncio.to_thread(reranker.rerank, "ping", [placeholder], 1) @router.post("/models/ping") async def ping_model_connections(): """Actively test each configured model with a minimal request, then return fresh statuses. Each ping is isolated with return_exceptions=True so one model timing out or erroring does not prevent the other three from completing and being reported. Failures are still visible afterwards via _build_model_status() because the underlying clients record their own outcome into the tracker. """ tasks = [ _ping_main_or_hyde("main_llm"), _ping_main_or_hyde("hyde_llm"), _ping_embedding(), _ping_reranker(), ] await asyncio.gather(*tasks, return_exceptions=True) return {"models": [_build_model_status(role) for role in _MODEL_ROLES]} @router.get("/mcp") async def get_mcp_server_status(request: Request): """Return MCP endpoint config, advertised tools, and per-tool call counters. This route is a thin HTTP adapter: everything MCP-specific is assembled by app.mcp.server.get_mcp_status(). The only thing decided here is the public URL, because only the HTTP layer knows how the client reached us. """ # request.base_url already carries scheme/host/port and a trailing slash; # strip it before appending so the result is ".../mcp/", not ".../mcp//". public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/" return await get_mcp_status(public_url)