feat(ai): Phase 24-A+B1 — AI Provider Registry + 絞殺者包裝 (ADR-052)
Some checks failed
E2E Health Check / e2e-health (push) Successful in 16s
CD Pipeline / build-and-deploy (push) Has been cancelled

Brain Layer 雙軌 Registry 架構:
- 新建 src/services/ai_providers/ 目錄 (interfaces + 4 providers)
  - OllamaProvider (local, rca/chat/code_review)
  - GeminiProvider (cloud, rca/chat)
  - ClaudeProvider (cloud, rca/chat/code_review)
  - OpenClawNemoProvider (cloud, rca — 委派 188→NIM)
- 擴展 ai_router.py 加入:
  - AIProviderRegistry (動態註冊/啟停)
  - AIRouterExecutor (Cache + 閘門 CB/RL/Sem + 執行)
- openclaw.py 絞殺者包裝: USE_AI_ROUTER=true 走新路徑
- config.py + ConfigMap 加入 USE_AI_ROUTER=false (安全預設)
- ADR-052 正式文件 (14 項決策 D1-D14)
- HARD_RULES v1.7 加入 AI Router 規範

安全: USE_AI_ROUTER=false 預設不啟用,需手動開啟觀察
回滾: kubectl set env deployment/awoooi-api USE_AI_ROUTER=false

2026-04-02 ogt: Phase 24 首批實作

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 13:16:09 +08:00
parent 1123eb4107
commit 73e8f8ab77
13 changed files with 1699 additions and 9 deletions

View File

@@ -52,6 +52,16 @@ class Settings(BaseSettings):
)
# ==========================================================================
# ==========================================================================
# Phase 24: AI Provider Registry (ADR-052)
# 2026-04-02 ogt: 絞殺者開關 — true=新 AIRouter, false=舊 openclaw.py if/else
# 回滾指令: kubectl set env deployment/awoooi-api USE_AI_ROUTER=false
# ==========================================================================
USE_AI_ROUTER: bool = Field(
default=False,
description="Phase 24: True=新 AIRouter 路由, False=舊 openclaw.py fallback chain",
)
# Phase 22: OpenClaw + Nemotron 協作 (ADR-044)
# 2026-03-31 Claude Code: 統帥批准實作
#

View File

@@ -0,0 +1,14 @@
"""
AI Providers - Phase 24 ADR-052
================================
AI Provider Registry & Dual-Track Routing Architecture
每個 Provider 是純粹的 LLM 呼叫單元 (Stateless Compute Unit)。
所有閘門控制 (CB/RL/Sem)、Cache、Trace 由 AIRouter 統一管理。
2026-04-02 ogt: Phase 24-A 建立
"""
from src.services.ai_providers.interfaces import AIProvider, AIResult
__all__ = ["AIProvider", "AIResult"]

View File

@@ -0,0 +1,160 @@
"""
Claude Provider - Phase 24 ADR-052
====================================
Anthropic Claude API (claude-3-haiku, Tool Use 強制 JSON)
搬移自: openclaw.py _call_claude (L474-550)
特性: 最強推理、昂貴、CRITICAL/DELETE 強制使用
2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出
"""
from __future__ import annotations
import json
import time
import httpx
import structlog
from src.core.config import get_settings
from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env
logger = structlog.get_logger(__name__)
settings = get_settings()
class ClaudeProvider:
"""
Anthropic Claude Cloud Provider
privacy_level: cloud (禁止機密資料)
capabilities: rca, chat, code_review
"""
def __init__(self) -> None:
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0))
return self._http_client
@property
def name(self) -> str:
return "claude"
@property
def is_enabled(self) -> bool:
if not settings.CLAUDE_API_KEY:
return False
return is_provider_enabled_by_env("claude")
@property
def capabilities(self) -> set[str]:
return {"rca", "chat", "code_review"}
@property
def privacy_level(self) -> str:
return "cloud"
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
if not settings.CLAUDE_API_KEY:
return AIResult(raw_response="", success=False, provider=self.name, error="CLAUDE_API_KEY not configured")
start = time.perf_counter()
try:
client = await self._get_client()
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": settings.CLAUDE_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": "claude-3-haiku-20240307",
"max_tokens": 2048,
"messages": [{"role": "user", "content": prompt}],
"tools": [{
"name": "submit_analysis",
"description": "Submit the RCA analysis result in structured format",
"input_schema": {
"type": "object",
"properties": {
"action_title": {"type": "string"},
"description": {"type": "string"},
"suggested_action": {"type": "string", "enum": ["RESTART_DEPLOYMENT", "DELETE_POD", "SCALE_DEPLOYMENT", "NO_ACTION"]},
"kubectl_command": {"type": "string"},
"target_resource": {"type": "string"},
"namespace": {"type": "string"},
"risk_level": {"type": "string", "enum": ["low", "medium", "critical"]},
"blast_radius": {
"type": "object",
"properties": {
"affected_pods": {"type": "integer"},
"estimated_downtime": {"type": "string"},
"related_services": {"type": "array", "items": {"type": "string"}},
"data_impact": {"type": "string", "enum": ["NONE", "READ_ONLY", "WRITE", "DESTRUCTIVE"]},
},
"required": ["affected_pods", "estimated_downtime", "related_services", "data_impact"],
},
"reasoning": {"type": "string"},
"deviation_analysis": {"type": "string"},
"confidence": {"type": "number"},
"affected_services": {"type": "array", "items": {"type": "string"}},
},
"required": ["action_title", "description", "suggested_action", "kubectl_command", "target_resource", "namespace", "risk_level", "blast_radius", "reasoning", "confidence"],
},
}],
"tool_choice": {"type": "tool", "name": "submit_analysis"},
},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
# 從 Tool Use 回應中提取 JSON
for block in data.get("content", []):
if block.get("type") == "tool_use" and block.get("name") == "submit_analysis":
tool_input = block.get("input", {})
latency = (time.perf_counter() - start) * 1000
logger.info("claude_provider_success", keys=list(tool_input.keys()), latency_ms=round(latency, 1))
return AIResult(
raw_response=json.dumps(tool_input),
success=True,
provider=self.name,
latency_ms=latency,
)
# Fallback: text content
for block in data.get("content", []):
if block.get("type") == "text":
latency = (time.perf_counter() - start) * 1000
return AIResult(
raw_response=block.get("text", ""),
success=True,
provider=self.name,
latency_ms=latency,
)
latency = (time.perf_counter() - start) * 1000
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error="No valid response")
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("claude_provider_failed", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e))
async def health_check(self) -> bool:
return bool(settings.CLAUDE_API_KEY)
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None

View File

@@ -0,0 +1,127 @@
"""
Gemini Provider - Phase 24 ADR-052
====================================
Google Gemini Cloud API (gemini-2.0-flash)
搬移自: openclaw.py _call_gemini (L411-472)
特性: 穩定快速、JSON 輸出可靠、有費用 ($0.075/1M input)
2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出
"""
from __future__ import annotations
import time
import httpx
import structlog
from src.core.config import get_settings
from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
settings = get_settings()
class GeminiProvider:
"""
Google Gemini Cloud Provider
privacy_level: cloud (禁止機密資料)
capabilities: rca, chat
"""
def __init__(self) -> None:
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0))
return self._http_client
@property
def name(self) -> str:
return "gemini"
@property
def is_enabled(self) -> bool:
if not settings.GEMINI_API_KEY:
return False
return is_provider_enabled_by_env("gemini")
@property
def capabilities(self) -> set[str]:
return {"rca", "chat"}
@property
def privacy_level(self) -> str:
return "cloud"
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
if not settings.GEMINI_API_KEY:
return AIResult(raw_response="", success=False, provider=self.name, error="GEMINI_API_KEY not configured")
start = time.perf_counter()
try:
client = await self._get_client()
registry = get_model_registry()
model_name = registry.get_model("gemini", "rca")
response = await client.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={settings.GEMINI_API_KEY}",
json={
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": 0.1,
"maxOutputTokens": 2048,
"responseMimeType": "application/json",
},
},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
text = data["candidates"][0]["content"]["parts"][0]["text"]
# Token/Cost 追蹤
usage = data.get("usageMetadata", {})
prompt_tokens = usage.get("promptTokenCount", 0)
completion_tokens = usage.get("candidatesTokenCount", 0)
total_tokens = usage.get("totalTokenCount", prompt_tokens + completion_tokens)
# Gemini 1.5 Flash: Input $0.075/1M, Output $0.30/1M
cost_usd = (prompt_tokens * 0.000000075) + (completion_tokens * 0.0000003)
latency = (time.perf_counter() - start) * 1000
logger.info(
"gemini_provider_success",
response_length=len(text),
total_tokens=total_tokens,
cost_usd=f"${cost_usd:.6f}",
latency_ms=round(latency, 1),
)
return AIResult(
raw_response=text,
success=True,
provider=self.name,
tokens=total_tokens,
cost_usd=cost_usd,
latency_ms=latency,
)
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("gemini_provider_failed", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e))
async def health_check(self) -> bool:
return bool(settings.GEMINI_API_KEY)
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None

View File

@@ -0,0 +1,152 @@
"""
AI Provider Interfaces - Phase 24 ADR-052
==========================================
定義 AI Provider 標準介面,類比 MCP interfaces.py (ADR-015)
設計原則:
1. Interface 先行 (Contract-First) — leWOOOgo 積木化鐵律
2. Provider 只負責「呼叫 LLM」不管閘門/Cache/Trace
3. AIResult 標準化回傳,供 AIRouter 統一處理
2026-04-02 ogt: Phase 24-A 建立
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
# =============================================================================
# AIResult — 標準化 AI 分析結果
# =============================================================================
@dataclass
class AIResult:
"""
AI 分析標準化結果
所有 Provider 必須回傳此格式,供 AIRouter 統一處理。
類比 MCP 的 MCPToolResult。
"""
raw_response: str
success: bool
provider: str # e.g., "ollama", "gemini", "openclaw_nemo"
from_cache: bool = False
tokens: int = 0
cost_usd: float = 0.0
latency_ms: float = 0.0
error: str | None = None
def to_dict(self) -> dict:
return {
"raw_response": self.raw_response[:500],
"success": self.success,
"provider": self.provider,
"from_cache": self.from_cache,
"tokens": self.tokens,
"cost_usd": self.cost_usd,
"latency_ms": round(self.latency_ms, 2),
"error": self.error,
}
# =============================================================================
# AIProvider Protocol — 所有 LLM 引擎必須實作
# =============================================================================
@runtime_checkable
class AIProvider(Protocol):
"""
AI Provider 標準介面 — Brain Layer 的 MCPToolProvider
設計原則 (ADR-052 D3):
- Provider 是「純粹的運算單元 (Stateless Compute Units)」
- 所有閘門 (CB/RL/Sem)、Cache、Trace 由 AIRouter 管理
- Provider 只負責: 接收 prompt → 呼叫 LLM → 回傳 AIResult
隱私規則 (ADR-052 D7):
- privacy_level="local" → 可處理 DIAGNOSE/CODE_REVIEW (機密資料)
- privacy_level="cloud" → 禁止接收機密資料 (零信任)
"""
@property
def name(self) -> str:
"""Provider 唯一名稱 (e.g., 'ollama', 'gemini', 'openclaw_nemo')"""
...
@property
def is_enabled(self) -> bool:
"""根據環境變數 ENABLE_{NAME} 動態判斷是否啟用"""
...
@property
def capabilities(self) -> set[str]:
"""
支援的能力集合
標準能力:
- "rca": Root Cause Analysis (Incident 仲裁)
- "tool_calling": K8s Tool Calling (kubectl 指令生成)
- "code_review": 代碼審查
- "chat": 一般對話
"""
...
@property
def privacy_level(self) -> str:
"""
隱私等級: "local" | "cloud"
ADR-052 D7 + 首席架構師 Q2 裁示:
- "local": 本地運算,可處理機密資料
- "cloud": 雲端 API禁止機密資料 (即使有企業協議)
"""
...
async def analyze(
self,
prompt: str,
context: dict[str, Any] | None = None,
) -> AIResult:
"""
執行 AI 分析
Args:
prompt: 完整的 LLM prompt
context: 額外上下文 (alert_type, target_resource 等)
Returns:
AIResult: 標準化結果
"""
...
async def health_check(self) -> bool:
"""健康檢查 (供 /health endpoint 和 AIRouter 動態路由)"""
...
# =============================================================================
# 工具函數
# =============================================================================
def is_provider_enabled_by_env(provider_name: str) -> bool:
"""
根據環境變數判斷 Provider 是否啟用
環境變數格式: ENABLE_{PROVIDER_NAME_UPPER}
例如: ENABLE_OLLAMA, ENABLE_GEMINI, ENABLE_OPENCLAW_NEMO
Args:
provider_name: Provider 名稱 (e.g., "ollama", "openclaw_nemo")
Returns:
bool: 是否啟用 (預設 True)
"""
env_key = f"ENABLE_{provider_name.upper()}"
return os.getenv(env_key, "true").lower() == "true"

View File

@@ -0,0 +1,123 @@
"""
Ollama Provider - Phase 24 ADR-052
====================================
本地 LLM 推理 (192.168.0.188 VMware VM, CPU-only)
搬移自: openclaw.py _call_ollama (L349-409)
特性: 免費、隱私安全 (local)、但 CPU 慢 (~97s/30tokens for qwen2.5:7b)
2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出
"""
from __future__ import annotations
import time
import httpx
import structlog
from src.core.config import get_settings
from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
settings = get_settings()
class OllamaProvider:
"""
Ollama 本地 LLM Provider
privacy_level: local (可處理機密資料)
capabilities: rca, chat, code_review
"""
def __init__(self) -> None:
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(120.0, connect=10.0),
)
return self._http_client
@property
def name(self) -> str:
return "ollama"
@property
def is_enabled(self) -> bool:
return is_provider_enabled_by_env("ollama")
@property
def capabilities(self) -> set[str]:
return {"rca", "chat", "code_review"}
@property
def privacy_level(self) -> str:
return "local"
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
start = time.perf_counter()
try:
client = await self._get_client()
registry = get_model_registry()
model_name = registry.get_model("ollama", "rca")
options = registry.get_provider_options("ollama")
response = await client.post(
f"{settings.OLLAMA_URL}/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"num_predict": options.get("num_predict", 1024),
"temperature": options.get("temperature", 0.1),
"top_p": options.get("top_p", 0.9),
},
},
timeout=httpx.Timeout(float(settings.OPENCLAW_TIMEOUT), connect=10.0),
)
response.raise_for_status()
data = response.json()
result = data.get("response", "")
latency = (time.perf_counter() - start) * 1000
logger.info("ollama_provider_success", response_length=len(result), latency_ms=round(latency, 1))
return AIResult(
raw_response=result,
success=True,
provider=self.name,
latency_ms=latency,
)
except httpx.TimeoutException as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("ollama_provider_timeout", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=f"Timeout: {e}")
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("ollama_provider_failed", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e))
async def health_check(self) -> bool:
try:
client = await self._get_client()
resp = await client.get(f"{settings.OLLAMA_URL}/api/tags", timeout=5.0)
return resp.status_code == 200
except Exception:
return False
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None

View File

@@ -0,0 +1,170 @@
"""
OpenClaw Nemo Provider - Phase 24 ADR-052
==========================================
委派 Incident RCA 給 OpenClaw (192.168.0.188) → NVIDIA NIM
搬移自: openclaw.py _call_openclaw_analyze (L263-345)
特性: AI 大腦委派路徑,免費 NVIDIA NIM GPU (~3s)
架構鐵律: AWOOOI API → OpenClaw → NVIDIA NIM (meta/llama-3.1-8b-instruct)
2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出
"""
from __future__ import annotations
import json as _json
import time
from typing import Any
import httpx
import structlog
from src.core.config import get_settings
from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env
logger = structlog.get_logger(__name__)
settings = get_settings()
def _to_serializable(obj: Any) -> Any:
"""
遞迴轉換不可序列化的物件 (e.g., datetime)
2026-04-01 ogt: signals dict 含 datetime → httpx json= 序列化失敗
此函數確保所有資料都可以被 JSON 序列化
"""
if isinstance(obj, dict):
return {k: _to_serializable(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_to_serializable(i) for i in obj]
try:
_json.dumps(obj)
return obj
except (TypeError, ValueError):
return str(obj)
class OpenClawNemoProvider:
"""
OpenClaw 委派 Provider (188 → NVIDIA NIM)
privacy_level: cloud (NIM 是雲端 GPU首席架構師 Q2 裁示)
capabilities: rca (Incident 仲裁專用)
呼叫路徑:
AWOOOI API (K8s) → OpenClaw (192.168.0.188:8088) → NVIDIA NIM → 回傳
"""
def __init__(self) -> None:
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(130.0, connect=5.0),
)
return self._http_client
@property
def name(self) -> str:
return "openclaw_nemo"
@property
def is_enabled(self) -> bool:
return is_provider_enabled_by_env("openclaw_nemo")
@property
def capabilities(self) -> set[str]:
return {"rca"}
@property
def privacy_level(self) -> str:
return "cloud"
async def analyze(
self,
prompt: str,
context: dict | None = None,
) -> AIResult:
"""
委派 Incident RCA 給 OpenClaw
注意: 此 Provider 使用 context 中的結構化資料 (incident_id, severity, signals)
而非純 prompt 字串。如果 context 缺少這些欄位,會 fallback 到用 prompt。
"""
start = time.perf_counter()
context = context or {}
try:
client = await self._get_client()
# 如果有結構化 context使用 /api/v1/analyze/incident
if context.get("incident_id"):
payload = {
"incident_id": context["incident_id"],
"severity": context.get("severity", "P3"),
"signals": _to_serializable(context.get("signals", [])[:5]),
"affected_services": context.get("affected_services", []),
"expert_context": _to_serializable(context.get("expert_context")) if context.get("expert_context") else None,
}
resp = await client.post(
f"{settings.OPENCLAW_URL}/api/v1/analyze/incident",
json=payload,
)
else:
# Fallback: 用 prompt 作為通用分析
resp = await client.post(
f"{settings.OPENCLAW_URL}/api/v1/analyze/incident",
json={
"incident_id": "UNKNOWN",
"severity": "P3",
"signals": [{"alert_name": "manual_analysis", "description": prompt[:500]}],
"affected_services": ["unknown"],
},
)
resp.raise_for_status()
data = resp.json()
# 驗證必要欄位
if not data.get("action_title") or not data.get("risk_level"):
latency = (time.perf_counter() - start) * 1000
logger.warning("openclaw_nemo_invalid_response", data_keys=list(data.keys()))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error="Invalid response structure")
# 轉換為 JSON 字串 (AIResult.raw_response 是 str)
result_json = _json.dumps(data, ensure_ascii=False)
latency = (time.perf_counter() - start) * 1000
logger.info(
"openclaw_nemo_provider_success",
confidence=data.get("confidence", 0),
risk_level=data.get("risk_level"),
latency_ms=round(latency, 1),
)
return AIResult(
raw_response=result_json,
success=True,
provider=self.name,
latency_ms=latency,
)
except Exception as e:
latency = (time.perf_counter() - start) * 1000
logger.warning("openclaw_nemo_provider_failed", error=str(e), latency_ms=round(latency, 1))
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e))
async def health_check(self) -> bool:
try:
client = await self._get_client()
resp = await client.get(f"{settings.OPENCLAW_URL}/health", timeout=5.0)
return resp.status_code == 200
except Exception:
return False
async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
self._http_client = None

View File

@@ -616,19 +616,312 @@ class AIRouter:
]
# 單例
# =============================================================================
# Phase 24 ADR-052: AI Provider Registry + Execution Layer
# =============================================================================
# 2026-04-02 ogt: 在現有 AIRouter (路由決策) 之上,加入 Provider 執行層
# 整合: ProviderRegistry + 閘門 (CB/RL/Sem) + Cache + Langfuse Trace
#
# 呼叫關係:
# openclaw.py → AIRouterExecutor.execute() → AIRouter.route() → Provider.analyze()
# =============================================================================
import asyncio
import hashlib
import json as _json
from src.core.config import get_settings
from src.services.ai_providers.interfaces import AIProvider as AIProviderProtocol, AIResult
_settings = get_settings()
class AIProviderRegistry:
"""
AI Provider 註冊中心 — 類比 MCP ProviderRegistry (ADR-015)
動態管理 AI Provider 的生命週期與啟停狀態。
"""
def __init__(self) -> None:
self._providers: dict[str, AIProviderProtocol] = {}
def register(self, provider: AIProviderProtocol) -> None:
"""註冊 Provider (啟動時呼叫)"""
self._providers[provider.name] = provider
status = "enabled" if provider.is_enabled else "disabled"
logger.info("ai_provider_registered", name=provider.name, status=status, privacy=provider.privacy_level)
def get(self, name: str) -> AIProviderProtocol | None:
"""取得已啟用的 Provider"""
p = self._providers.get(name)
if p and p.is_enabled:
return p
return None
def all_enabled(self) -> list[AIProviderProtocol]:
"""取得所有已啟用的 Provider"""
return [p for p in self._providers.values() if p.is_enabled]
def names(self) -> list[str]:
"""所有已註冊 Provider 名稱"""
return list(self._providers.keys())
async def health_check_all(self) -> dict[str, bool]:
"""所有 Provider 健康狀態"""
results = {}
for name, p in self._providers.items():
try:
results[name] = await p.health_check()
except Exception:
results[name] = False
return results
class AIRouterExecutor:
"""
AI Router 執行層 (Phase 24 ADR-052)
職責:
1. Cache 檢查 (Redis, 跨 Provider 共享) — D4
2. 閘門控制 (Circuit Breaker → Rate Limiter → Semaphore) — D3
3. 呼叫 Provider.analyze() — 實際執行
4. 記錄 Langfuse Trace — D5
5. Mock Mode 攔截 — D13
設計原則:
- 只依賴 AIProviderProtocol禁止 import 具體 Provider 類別
- 閘門在 RouterProvider 保持純粹 (Stateless Compute Units)
"""
def __init__(self, registry: AIProviderRegistry) -> None:
self._registry = registry
self._semaphores: dict[str, asyncio.Semaphore] = {}
def _get_semaphore(self, name: str, limit: int = 3) -> asyncio.Semaphore:
"""取得 Provider 的並發 Semaphore (lazy init)"""
if name not in self._semaphores:
self._semaphores[name] = asyncio.Semaphore(limit)
return self._semaphores[name]
@staticmethod
def _cache_key(prompt: str, context: dict | None) -> str:
"""生成 Cache Key (與 openclaw.py 相容)"""
ctx_hash = ""
if context:
ctx_hash = f":{context.get('alert_type', '')}:{context.get('target_resource', '')}"
content = f"{prompt}{ctx_hash}"
return f"llm_cache:{hashlib.sha256(content.encode()).hexdigest()[:16]}"
async def execute(
self,
prompt: str,
provider_order: list[str],
context: dict | None = None,
cache_ttl: int = 3600,
require_local: bool = False,
) -> AIResult:
"""
核心執行方法 — 依序嘗試 Provider含閘門 + Cache
Args:
prompt: LLM prompt
provider_order: Provider 名稱順序 (由 AIRouter.route 決定)
context: 額外上下文
cache_ttl: Cache TTL (秒)
require_local: 強制 local Provider (隱私)
Returns:
AIResult: 標準化結果
"""
# ① Mock Mode 攔截 (D13)
if _settings.MOCK_MODE:
logger.info("ai_router_mock_mode")
return AIResult(
raw_response=_json.dumps({
"action_title": "Mock Analysis",
"description": "Mock mode enabled",
"risk_level": "low",
"reasoning": "MOCK_MODE=true",
"confidence": 0.0,
}),
success=True,
provider="mock",
)
# ② Cache 檢查 (D4)
try:
from src.core.redis_client import get_redis
redis = get_redis()
cache_key = self._cache_key(prompt, context)
cached = await redis.get(cache_key)
if cached:
data = _json.loads(cached)
logger.info("ai_router_cache_hit", cache_key=cache_key[:30])
return AIResult(
raw_response=data.get("response", ""),
success=True,
provider=data.get("provider", "cache"),
from_cache=True,
)
except Exception as e:
logger.debug("ai_router_cache_read_failed", error=str(e))
# ③ 遍歷 Provider + 閘門 (D3)
errors: list[str] = []
for provider_name in provider_order:
provider = self._registry.get(provider_name)
if not provider:
continue
# 隱私過濾 (D7)
if require_local and provider.privacy_level != "local":
continue
# 閘門 1: Circuit Breaker
try:
from src.core.circuit_breaker import get_openclaw_guard
guard = get_openclaw_guard()
if guard.is_circuit_open():
logger.debug("ai_router_circuit_open", provider=provider_name)
continue
except Exception:
pass # Circuit Breaker 不阻塞主流程
# 閘門 2: Rate Limiter
if provider_name in ("nvidia", "gemini", "claude"):
try:
from src.services.ai_rate_limiter import get_ai_rate_limiter
rate_limiter = get_ai_rate_limiter()
allowed, reason = await rate_limiter.check_and_increment(provider_name)
if not allowed:
logger.info("ai_router_rate_limited", provider=provider_name, reason=reason)
continue
except Exception as e:
logger.debug("ai_router_rate_limiter_error", error=str(e))
# 閘門 3: Semaphore (並發控制)
sem = self._get_semaphore(provider_name)
async with sem:
try:
result = await provider.analyze(prompt, context)
if result.success:
# 記錄成功
try:
guard = get_openclaw_guard()
guard.record_success()
except Exception:
pass
# 記錄費用
if result.cost_usd > 0:
try:
rate_limiter = get_ai_rate_limiter()
await rate_limiter.record_cost(provider_name, result.cost_usd)
except Exception:
pass
# 寫入 Cache (D4)
try:
redis = get_redis()
cache_data = _json.dumps({
"response": result.raw_response,
"provider": result.provider,
"cached_at": time.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
})
await redis.set(cache_key, cache_data, ex=cache_ttl)
except Exception:
pass
logger.info(
"ai_router_execute_success",
provider=provider_name,
latency_ms=round(result.latency_ms, 1),
tokens=result.tokens,
from_cache=False,
)
return result
# Provider 回傳 success=False
errors.append(f"{provider_name}: {result.error}")
logger.warning("ai_router_provider_failed", provider=provider_name, error=result.error)
except Exception as e:
errors.append(f"{provider_name}: {e}")
logger.warning("ai_router_provider_exception", provider=provider_name, error=str(e))
try:
guard = get_openclaw_guard()
guard.record_failure()
except Exception:
pass
# 全部失敗
logger.error("ai_router_all_providers_failed", tried=provider_order, errors=errors)
return AIResult(
raw_response="",
success=False,
provider="none",
error=f"All providers failed: {'; '.join(errors)}",
)
# =============================================================================
# 單例管理
# =============================================================================
_router: AIRouter | None = None
_registry: AIProviderRegistry | None = None
_executor: AIRouterExecutor | None = None
def _init_registry() -> AIProviderRegistry:
"""初始化 Provider Registry (首次呼叫時自動註冊所有 Provider)"""
from src.services.ai_providers.ollama import OllamaProvider
from src.services.ai_providers.gemini import GeminiProvider
from src.services.ai_providers.claude import ClaudeProvider
from src.services.ai_providers.openclaw_nemo import OpenClawNemoProvider
registry = AIProviderRegistry()
registry.register(OllamaProvider())
registry.register(GeminiProvider())
registry.register(ClaudeProvider())
registry.register(OpenClawNemoProvider())
# NvidiaProvider 整合現有的 nvidia_provider.py (Phase 24-B3)
# 暫時不註冊Tool Calling 仍走現有路徑
return registry
def get_ai_router() -> AIRouter:
"""取得 AIRouter 單例"""
"""取得 AIRouter 單例 (路由決策)"""
global _router
if _router is None:
_router = AIRouter()
return _router
def get_ai_registry() -> AIProviderRegistry:
"""取得 AIProviderRegistry 單例"""
global _registry
if _registry is None:
_registry = _init_registry()
return _registry
def get_ai_executor() -> AIRouterExecutor:
"""取得 AIRouterExecutor 單例 (路由決策 + 執行)"""
global _executor
if _executor is None:
_executor = AIRouterExecutor(get_ai_registry())
return _executor
def reset_ai_router() -> None:
"""重置單例 (用於測試)"""
global _router
"""重置所有單例 (用於測試)"""
global _router, _registry, _executor
_router = None
_registry = None
_executor = None

View File

@@ -907,7 +907,35 @@ class OpenClawService:
Phase 15.1: 整合 Langfuse LLMOps 追蹤
2026-03-29 ogt: 加入 Token/Cost 追蹤
2026-04-02 ogt: Phase 24 ADR-052 絞殺者包裝 — USE_AI_ROUTER 新舊並存
"""
# =================================================================
# Phase 24 ADR-052: 絞殺者分支 (Strangler Fig)
# USE_AI_ROUTER=true → 新 AIRouterExecutor 路由
# USE_AI_ROUTER=false → 舊 if/else fallback chain (現狀)
# 回滾: kubectl set env deployment/awoooi-api USE_AI_ROUTER=false
# =================================================================
if settings.USE_AI_ROUTER:
try:
from src.services.ai_router import get_ai_executor
executor = get_ai_executor()
result = await executor.execute(
prompt=prompt,
provider_order=settings.AI_FALLBACK_ORDER,
context=alert_context,
cache_ttl=3600,
)
logger.info(
"phase24_ai_router_used",
provider=result.provider,
success=result.success,
latency_ms=round(result.latency_ms, 1),
)
return result.raw_response, result.provider, result.success, result.tokens, result.cost_usd
except Exception as e:
# AIRouter 失敗時 fallback 到舊路徑 (安全網)
logger.warning("phase24_ai_router_fallback_to_legacy", error=str(e))
# Mock Mode: 開發測試用
if settings.MOCK_MODE:
logger.info("mock_mode_enabled", using="mock_llm")