feat(awooop): surface ai provider route status
This commit is contained in:
@@ -25,6 +25,9 @@ from src.core.awooop_operator_auth import (
|
||||
from src.services.platform_operator_service import (
|
||||
decide_approval as decide_approval_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
get_ai_route_status as get_ai_route_status_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
get_awooop_status_chain as get_awooop_status_chain_svc,
|
||||
)
|
||||
@@ -99,6 +102,21 @@ class ListCallbackRepliesResponse(BaseModel):
|
||||
per_page: int
|
||||
|
||||
|
||||
class AiRouteStatusResponse(BaseModel):
|
||||
schema_version: str
|
||||
workload_type: str
|
||||
policy_order: list[dict[str, Any]]
|
||||
selected_provider: str | None = None
|
||||
selected_url: str | None = None
|
||||
selected_model: str | None = None
|
||||
fallback_chain: list[dict[str, Any]]
|
||||
route_reason: str
|
||||
route_source: str
|
||||
route_error: str | None = None
|
||||
health: dict[str, dict[str, Any]]
|
||||
checked_at: datetime
|
||||
|
||||
|
||||
class ApprovalItem(BaseModel):
|
||||
run_id: UUID
|
||||
project_id: str
|
||||
@@ -198,6 +216,24 @@ async def list_callback_replies(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ai-route-status",
|
||||
response_model=AiRouteStatusResponse,
|
||||
summary="查詢 AI Provider 路由狀態",
|
||||
description=(
|
||||
"回傳目前 Ollama/Gemini 路由策略、即時 primary、fallback chain 與健康狀態;"
|
||||
"只讀,不觸發推理或自動修復。"
|
||||
),
|
||||
)
|
||||
async def get_ai_route_status(
|
||||
workload_type: str | None = Query(
|
||||
"deep_rca",
|
||||
description="工作負載類型,例如 deep_rca/hermes/interactive/embedding/rag/code_review/image_analysis",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
return await get_ai_route_status_svc(workload_type=workload_type)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{run_id}/detail",
|
||||
summary="查詢 Run 詳細時間線",
|
||||
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from typing import Any, get_args
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
@@ -38,6 +38,17 @@ from src.services.awooop_truth_chain_service import (
|
||||
_summarize_mcp,
|
||||
fetch_truth_chain,
|
||||
)
|
||||
from src.services.ollama_endpoint_resolver import (
|
||||
OllamaEndpointSelection,
|
||||
OllamaWorkloadType,
|
||||
resolve_ollama_order,
|
||||
)
|
||||
from src.services.ollama_failover_manager import (
|
||||
OllamaEndpoint,
|
||||
OllamaRoutingResult,
|
||||
get_ollama_failover_manager,
|
||||
)
|
||||
from src.services.ollama_health_monitor import HealthReport
|
||||
from src.services.run_state_machine import transition
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -74,6 +85,8 @@ _CALLBACK_REPLY_RAW_STATUS_BY_FILTER = {
|
||||
"failed": "callback_reply_failed",
|
||||
}
|
||||
_CALLBACK_REPLY_ACTION_RE = re.compile(r"^[a-z0-9_:-]{1,64}$", re.IGNORECASE)
|
||||
_AI_ROUTE_STATUS_SCHEMA_VERSION = "awooop_ai_route_status_v1"
|
||||
_AI_ROUTE_WORKLOADS = set(get_args(OllamaWorkloadType))
|
||||
|
||||
# =============================================================================
|
||||
# Tenants
|
||||
@@ -283,6 +296,7 @@ async def list_callback_replies(
|
||||
"limit": per_page,
|
||||
"offset": (page - 1) * per_page,
|
||||
}
|
||||
|
||||
if project_id:
|
||||
where_clauses.append("m.project_id = :project_id")
|
||||
params["project_id"] = project_id
|
||||
@@ -395,6 +409,150 @@ async def list_callback_replies(
|
||||
}
|
||||
|
||||
|
||||
async def get_ai_route_status(
|
||||
workload_type: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""回傳目前 AI/Ollama provider routing 的只讀狀態,供 Operator Console 顯示。"""
|
||||
workload = _validate_ai_route_workload(workload_type)
|
||||
policy_order = _ai_route_policy_order(workload)
|
||||
checked_at = _utc_now_naive()
|
||||
|
||||
try:
|
||||
route = await get_ollama_failover_manager().select_provider(task_type=workload)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"schema_version": _AI_ROUTE_STATUS_SCHEMA_VERSION,
|
||||
"workload_type": workload,
|
||||
"policy_order": policy_order,
|
||||
"selected_provider": None,
|
||||
"selected_url": None,
|
||||
"selected_model": None,
|
||||
"fallback_chain": [],
|
||||
"route_reason": "route_check_failed",
|
||||
"route_source": "ollama_failover_manager",
|
||||
"route_error": str(exc),
|
||||
"health": {},
|
||||
"checked_at": checked_at,
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": _AI_ROUTE_STATUS_SCHEMA_VERSION,
|
||||
"workload_type": workload,
|
||||
"policy_order": policy_order,
|
||||
"selected_provider": route.primary.provider_name,
|
||||
"selected_url": route.primary.url or None,
|
||||
"selected_model": route.primary.model,
|
||||
"fallback_chain": [
|
||||
_ai_route_runtime_endpoint_item(endpoint, priority=index + 2)
|
||||
for index, endpoint in enumerate(route.fallback_chain)
|
||||
],
|
||||
"route_reason": route.routing_reason,
|
||||
"route_source": "ollama_failover_manager",
|
||||
"route_error": None,
|
||||
"health": _ai_route_health_map(route),
|
||||
"checked_at": checked_at,
|
||||
}
|
||||
|
||||
|
||||
def _validate_ai_route_workload(workload_type: str | None) -> OllamaWorkloadType:
|
||||
"""Normalize and validate workload filter for the public route status endpoint."""
|
||||
workload = str(workload_type or "deep_rca").strip() or "deep_rca"
|
||||
if workload not in _AI_ROUTE_WORKLOADS:
|
||||
allowed = ", ".join(sorted(_AI_ROUTE_WORKLOADS))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unsupported workload_type: {workload}. Allowed: {allowed}",
|
||||
)
|
||||
return workload # type: ignore[return-value]
|
||||
|
||||
|
||||
def _ai_route_policy_order(workload: OllamaWorkloadType) -> list[dict[str, Any]]:
|
||||
"""Expose configured policy order: GCP-A -> GCP-B -> 111 -> Gemini."""
|
||||
items = [
|
||||
_ai_route_policy_endpoint_item(endpoint, priority=index + 1)
|
||||
for index, endpoint in enumerate(resolve_ollama_order(workload))
|
||||
]
|
||||
items.append({
|
||||
"priority": len(items) + 1,
|
||||
"provider_name": "gemini",
|
||||
"url": None,
|
||||
"workload_type": workload,
|
||||
"reason": "final_cloud_fallback_after_all_ollama_endpoints",
|
||||
"role": "final_fallback",
|
||||
"runtime": "cloud",
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _ai_route_policy_endpoint_item(
|
||||
endpoint: OllamaEndpointSelection,
|
||||
*,
|
||||
priority: int,
|
||||
) -> dict[str, Any]:
|
||||
role = {
|
||||
"ollama_gcp_a": "primary",
|
||||
"ollama_gcp_b": "secondary",
|
||||
"ollama_local": "local_fallback",
|
||||
}.get(endpoint.provider_name, "ollama")
|
||||
return {
|
||||
"priority": priority,
|
||||
"provider_name": endpoint.provider_name,
|
||||
"url": endpoint.url,
|
||||
"workload_type": endpoint.workload_type,
|
||||
"reason": endpoint.reason,
|
||||
"role": role,
|
||||
"runtime": "ollama",
|
||||
}
|
||||
|
||||
|
||||
def _ai_route_runtime_endpoint_item(
|
||||
endpoint: OllamaEndpoint,
|
||||
*,
|
||||
priority: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"priority": priority,
|
||||
"provider_name": endpoint.provider_name,
|
||||
"url": endpoint.url or None,
|
||||
"model": endpoint.model,
|
||||
"runtime": "ollama" if endpoint.provider_name.startswith("ollama") else "cloud",
|
||||
}
|
||||
|
||||
|
||||
def _ai_route_health_map(route: OllamaRoutingResult) -> dict[str, dict[str, Any]]:
|
||||
"""Convert failover health reports into provider keyed status for the UI."""
|
||||
health: dict[str, dict[str, Any]] = {
|
||||
"ollama_gcp_a": _ai_route_health_item(route.health_gcp_a),
|
||||
}
|
||||
if route.health_gcp_b:
|
||||
health["ollama_gcp_b"] = _ai_route_health_item(route.health_gcp_b)
|
||||
else:
|
||||
health["ollama_gcp_b"] = _ai_route_not_checked_health_item()
|
||||
if route.health_local:
|
||||
health["ollama_local"] = _ai_route_health_item(route.health_local)
|
||||
else:
|
||||
health["ollama_local"] = _ai_route_not_checked_health_item()
|
||||
return health
|
||||
|
||||
|
||||
def _ai_route_health_item(report: HealthReport) -> dict[str, Any]:
|
||||
payload = report.to_dict()
|
||||
payload["checked"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _ai_route_not_checked_health_item() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "not_checked",
|
||||
"host": "",
|
||||
"latency_ms": None,
|
||||
"reason": "standby_not_checked_primary_healthy",
|
||||
"checked_at": None,
|
||||
"from_cache": False,
|
||||
"checked": False,
|
||||
}
|
||||
|
||||
|
||||
def _timeline_item(
|
||||
*,
|
||||
ts: Any,
|
||||
|
||||
Reference in New Issue
Block a user