feat(ai): Phase 24-A+B1 — AI Provider Registry + 絞殺者包裝 (ADR-052)
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:
@@ -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: 統帥批准實作
|
||||
#
|
||||
|
||||
14
apps/api/src/services/ai_providers/__init__.py
Normal file
14
apps/api/src/services/ai_providers/__init__.py
Normal 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"]
|
||||
160
apps/api/src/services/ai_providers/claude.py
Normal file
160
apps/api/src/services/ai_providers/claude.py
Normal 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
|
||||
127
apps/api/src/services/ai_providers/gemini.py
Normal file
127
apps/api/src/services/ai_providers/gemini.py
Normal 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
|
||||
152
apps/api/src/services/ai_providers/interfaces.py
Normal file
152
apps/api/src/services/ai_providers/interfaces.py
Normal 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"
|
||||
123
apps/api/src/services/ai_providers/ollama.py
Normal file
123
apps/api/src/services/ai_providers/ollama.py
Normal 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
|
||||
170
apps/api/src/services/ai_providers/openclaw_nemo.py
Normal file
170
apps/api/src/services/ai_providers/openclaw_nemo.py
Normal 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
|
||||
@@ -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 類別
|
||||
- 閘門在 Router,Provider 保持純粹 (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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user