# ADR-052: AI Provider Registry & Dual-Track Routing Architecture

> **狀態**: 📝 草案 (Pending Approval)
> **日期**: 2026-04-02
> **決策者**: 統帥 + 首席架構師
> **首席架構師評分**: 100% 技術背書
> **關聯 ADR**: ADR-006, ADR-015, ADR-017, ADR-023, ADR-024, ADR-036, ADR-044
> **Memory**: `project_phase24_ai_router.md`

---

## 背景

### 問題陳述

`openclaw.py` 已成長為 **1757 行的上帝類 (God Class)**，同時承擔 AI Provider 路由、快取管理、閘門控制、Langfuse 追蹤、SignOz 整合、結果解析、調優指令生成等 7+ 種職責。

AI Provider 的選擇與降級邏輯以 if/else hardcode 實作，無法即時切換：

```python
# 現狀 — openclaw.py:945-980
for provider in settings.AI_FALLBACK_ORDER:
    if provider == "ollama":
        response, success = await self._call_ollama(prompt)
    elif provider == "gemini":
        response, success, tokens, cost = await self._call_gemini(prompt)
    elif provider == "nvidia":
        ...
    elif provider == "claude":
        response, success = await self._call_claude(prompt)
```

### 對比：Action Layer 已有優雅解法

MCP Bridge (ADR-015) 已在 Action Layer 實作了 ProviderRegistry 模式：

```
Action Layer: MCP Bridge → ProviderRegistry → K8sProvider / SignOzProvider / DBProvider
Brain Layer:  openclaw.py → if/else → _call_ollama / _call_gemini / _call_claude  ← 問題所在
```

### 觸發原因

1. 統帥提問：「OpenClaw / Ollama / Nemo 有沒有即時開關？」— 答案是**沒有**
2. 首席架構師評估：Brain Layer 的 hardcoded dependencies 是系統成長期最大的技術債
3. 已驗證的 ProviderRegistry 模式可直接複製到 Brain Layer

---

## 決策

### 1. 雙軌 Registry 架構 (Dual-Registry Architecture)

將 MCP 已驗證的 ProviderRegistry 模式「向上」複製到 Brain Layer：

```
                     [ Webhook / Telegram / Web ]
                                │
    ┌───────────────────────────┼───────────────────────────┐
    │                    BRAIN LAYER                        │
    │  ┌─────────────────────────────────────────────────┐  │
    │  │           AIRouter (ai/router.py)               │  │
    │  │  ┌──────┐ ┌──────┐ ┌──────┐ ┌───────┐ ┌─────┐ │  │
    │  │  │OpenClaw│ │Gemini│ │Ollama│ │Nemotron│ │Claude│ │  │
    │  │  │ Nemo │ │      │ │      │ │(Tool) │ │     │ │  │
    │  │  └──────┘ └──────┘ └──────┘ └───────┘ └─────┘ │  │
    │  │     ConfigMap ENABLE_* 開關 (熱切換)            │  │
    │  └─────────────────────────────────────────────────┘  │
    ├───────────────────────────────────────────────────────┤
    │                    ACTION LAYER                       │
    │  ┌─────────────────────────────────────────────────┐  │
    │  │         MCP Bridge (mcp/mcp_bridge.py)          │  │
    │  │  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐       │  │
    │  │  │  K8s │ │SignOz│ │  DB  │ │Filesystem│       │  │
    │  │  └──────┘ └──────┘ └──────┘ └──────────┘       │  │
    │  └─────────────────────────────────────────────────┘  │
    └───────────────────────────────────────────────────────┘
```

### 2. OpenClawService 瘦身，不消滅

```
【遷移前】openclaw.py (1757 行上帝類)
┌─────────────────────────────────────────────────┐
│ SignOz 整合           → 保留                     │
│ Prompt 組裝           → 保留                     │
│ _call_ollama()        → 抽出為 OllamaProvider    │
│ _call_gemini()        → 抽出為 GeminiProvider    │
│ _call_claude()        → 抽出為 ClaudeProvider    │
│ _call_openclaw()      → 抽出為 OpenClawNemoProvider │
│ _call_with_cache()    → 移到 AIRouter            │
│ _try_fallback_chain() → 移到 AIRouter            │
│ Rate Limiter 整合     → 移到 AIRouter            │
│ Circuit Breaker       → 移到 AIRouter            │
│ Langfuse Trace        → 移到 AIRouter            │
│ Mock Mode             → 移到 AIRouter            │
│ 結果解析              → 保留                     │
│ Auto-tuning           → 保留                     │
│ ILLMProvider.call()   → 保留 (內部改呼叫 Router) │
└─────────────────────────────────────────────────┘

【遷移後】
┌── openclaw.py (~800 行，業務編排) ──────────────┐
│ SignOz 整合                                      │
│ Prompt 組裝                                      │
│ 呼叫 AIRouter.route() ← 唯一改動點              │
│ 結果解析 + proposal_dict 組裝                    │
│ Auto-tuning                                      │
│ ILLMProvider.call() → 轉發給 AIRouter            │
└──────────────────────────────────────────────────┘

┌── ai/router.py (~300 行) ────────────────────────┐
│ Cache 層 (Redis, 跨 Provider 共享)               │
│ 閘門 (Circuit Breaker → Rate Limiter → Semaphore)│
│ Provider 選擇 + Fallback Chain                   │
│ Langfuse Trace (parent)                          │
│ Mock Mode 攔截                                   │
└──────────────────────────────────────────────────┘

┌── ai/providers/ (~60-100 行/each) ──────────────┐
│ ollama.py      → _call_ollama 直接搬             │
│ gemini.py      → _call_gemini 直接搬             │
│ claude.py      → _call_claude 直接搬             │
│ openclaw_nemo.py → _call_openclaw_analyze 直接搬 │
│ nvidia_nemotron.py → 整合現有 nvidia_provider.py │
└──────────────────────────────────────────────────┘
```

### 3. 檔案結構

```
apps/api/src/services/ai/
├── __init__.py
├── interfaces.py          # AIProvider Protocol + AIResult (類比 mcp/interfaces.py)
├── router.py              # AIRouter + Cache + 閘門 + Fallback (類比 mcp/registry.py)
├── providers/
│   ├── __init__.py
│   ├── ollama.py          # 從 openclaw.py _call_ollama 抽出
│   ├── gemini.py          # 從 openclaw.py _call_gemini 抽出
│   ├── claude.py          # 從 openclaw.py _call_claude 抽出
│   ├── openclaw_nemo.py   # 從 openclaw.py _call_openclaw_analyze 抽出
│   └── nvidia_nemotron.py # 整合現有 nvidia_provider.py
└── health.py              # AI Provider 健康檢查 (供 /health endpoint)
```

---

## 介面定義

### AIProvider Protocol

```python
from typing import Protocol, runtime_checkable, Any
from dataclasses import dataclass

@runtime_checkable
class AIProvider(Protocol):
    """AI Provider 標準介面 — 所有 LLM 引擎必須實作"""

    @property
    def name(self) -> str:
        """Provider 唯一名稱 (e.g., 'ollama', 'gemini', 'openclaw_nemo')"""
        ...

    @property
    def is_enabled(self) -> bool:
        """根據環境變數動態判斷是否啟用 (e.g., ENABLE_OLLAMA)"""
        ...

    @property
    def capabilities(self) -> set[str]:
        """支援的能力: {"rca", "tool_calling", "code_review", "chat"}"""
        ...

    @property
    def privacy_level(self) -> str:
        """'local' | 'cloud' — 決定是否可處理敏感資料 (ADR-023 零信任)"""
        ...

    async def analyze(
        self,
        prompt: str,
        context: dict[str, Any] | None = None,
        trace_context: Any | None = None,  # Langfuse Trace (ADR-017)
    ) -> "AIResult":
        """執行 AI 分析，回傳標準化結果"""
        ...

    async def health_check(self) -> bool:
        """健康檢查 (供 /health endpoint)"""
        ...


@dataclass
class AIResult:
    """AI 分析標準化結果"""
    raw_response: str
    success: bool
    provider: str
    from_cache: bool = False
    tokens: int = 0
    cost_usd: float = 0.0
    latency_ms: float = 0.0
```

### 與現有 ILLMProvider 的關係

```
ErrorAnalyzerService
    → ILLMProvider.call()           ← 消費者介面 (不動)
        → OpenClawService.call()    ← 業務編排 (保留)
            → AIRouter.route()      ← 新增
                → AIProvider.analyze()  ← 內部介面
```

兩個 Protocol 屬不同層次，共存不衝突：
- `ILLMProvider`: 消費者面向的簡化介面 (Service→Service)
- `AIProvider`: Router 內部的完整介面 (Router→Provider)

---

## 14 項架構決策

| # | 決策點 | 裁決 | 依據 |
|---|--------|------|------|
| **D1** | Intent/Complexity 整合 | Phase A 允許 None，退化為靜態 Fallback | 首席架構師: 先接好神經突觸，再植入評分細胞 |
| **D2** | 雙 Fallback Chain (RCA vs Tool Calling) | 單 Router + `task_type` 參數 | 避免重複代碼 |
| **D3** | 閘門 (CB/RL/Sem) 歸屬 | **Router 統一管控** | Provider 保持純粹 (Stateless Compute Units) |
| **D4** | Cache 歸屬 | **Router 內部** | 跨 Provider 共享同一 Cache Key |
| **D5** | Langfuse Trace 歸屬 | **Router 建立 parent Trace，Provider 寫 child Generation** | ADR-017 100% 追蹤全貌 |
| **D6** | 代碼位置 | `apps/api/src/services/ai/` | 務實優先，8 個呼叫端都在 src/ 內 |
| **D7** | 隱私強制 | **NIM = cloud，DIAGNOSE 強制 local** | 首席架構師: 零信任底線 |
| **D8** | 絞殺者開關 | `USE_AI_ROUTER` env var | 已驗證的 Strangler Fig 模式 (ADR-047) |
| **D9** | Phase C 動態控制 | Redis 優先，env var fallback | 即時切換不重啟 Pod |
| **D10** | packages/ 遷移時機 | Phase C 之後 | 先穩定再跨 package |
| **D11** | OpenClawService 去留 | **瘦身為業務編排層，不消滅** | 8 個外部呼叫端 API 不變 |
| **D12** | ILLMProvider vs AIProvider | **兩個不同層次介面，共存** | 消費者介面 vs 內部介面 |
| **D13** | MOCK_MODE | **Router 層攔截** (不進閘門) | Mock 是跳過整個 AI 決策 |
| **D14** | ConsensusEngine (P0/P1) | **不納入 AIRouter** | 獨立的決策層邏輯 (Claude Agent SDK) |

---

## AIRouter 核心流程

```python
class AIRouter:
    """
    Brain Layer 路由器 — 類比 MCP ProviderRegistry (ADR-015)

    職責:
    1. 動態啟用/停用 Provider (ConfigMap + Redis)
    2. Cache 層保護算力
    3. 閘門控制 (Circuit Breaker → Rate Limiter → Semaphore)
    4. Primary → Fallback 降級鏈
    5. Langfuse Trace 追蹤 (ADR-017)
    """

    async def route(
        self,
        prompt: str,
        task_type: str = "rca",           # "rca" | "tool_calling" | "chat"
        intent: str | None = None,         # ADR-023 (Phase A: 可選)
        complexity: int | None = None,     # ADR-023 (Phase A: 可選)
        context: dict | None = None,
        require_local: bool = False,       # 強制本地 Provider (隱私)
    ) -> AIResult:

        # ① Mock Mode 攔截 (D13)
        if settings.MOCK_MODE:
            return AIResult(raw_response=self._mock(context), provider="mock", success=True)

        # ② Cache 檢查 (D4)
        cached = await self._cache.get(prompt, context)
        if cached:
            return cached

        # ③ 建構 Provider 順序 (D1 + D2 + D7)
        order = self._resolve_provider_order(task_type, intent, complexity, require_local)

        # ④ Langfuse Trace (D5)
        with langfuse_trace("ai_router_decision", metadata={...}) as trace:

            # ⑤ 遍歷 Provider + 閘門 (D3)
            for provider_name in order:
                provider = self._providers.get(provider_name)
                if not provider or not provider.is_enabled:
                    continue

                # 閘門 1: Circuit Breaker
                if self._guards[provider_name].is_circuit_open():
                    continue

                # 閘門 2: Rate Limiter
                allowed, reason = await self._rate_limiter.check_and_increment(provider_name)
                if not allowed:
                    continue

                # 閘門 3: Semaphore (並發控制)
                async with self._semaphores[provider_name]:
                    try:
                        result = await provider.analyze(prompt, context, trace)
                        self._guards[provider_name].record_success()
                        await self._rate_limiter.record_cost(provider_name, result.cost_usd)
                        await self._cache.set(prompt, context, result)
                        return result
                    except Exception as e:
                        self._guards[provider_name].record_failure()
                        trace.event(f"{provider_name}_failed", metadata={"error": str(e)})
                        continue

            raise AllProvidersFailedError(tried=order)

    def _resolve_provider_order(self, task_type, intent, complexity, require_local):
        """
        三層決策優先級:
        1. 隱私強制 (ADR-023: DIAGNOSE/CODE_REVIEW → local only)
        2. Complexity 路由 (ADR-023: 4→Gemini, 5→Claude)
        3. Task-type Fallback Chain (ADR-006 + ADR-036)
        """
        # 層 1: 隱私強制
        if require_local or intent in ("DIAGNOSE", "CODE_REVIEW"):
            return [n for n, p in self._providers.items() if p.privacy_level == "local"]

        # 層 2: Complexity 路由 (Phase A: 可選)
        if complexity is not None and complexity >= 4:
            return self._complexity_routing.get(complexity, settings.AI_FALLBACK_ORDER)

        # 層 3: Task-type Fallback
        if task_type == "tool_calling":
            return settings.TOOL_CALLING_FALLBACK_ORDER  # ["nemotron","gemini","claude"]
        return settings.AI_FALLBACK_ORDER  # ["openclaw_nemo","gemini","ollama","claude"]
```

---

## ConfigMap 開關設計

```yaml
# k8s/awoooi-prod/04-configmap.yaml

# ============ Brain Layer: AI Provider 開關 ============
ENABLE_OPENCLAW_NEMO: "true"      # OpenClaw 委派 (188→NIM)
ENABLE_OLLAMA: "true"             # Ollama 本地 CPU
ENABLE_GEMINI: "true"             # Gemini 雲端
ENABLE_CLAUDE: "false"            # Claude (昂貴，僅緊急)
ENABLE_NEMOTRON: "true"           # Nemotron Tool Calling

# 路由策略
AI_PRIMARY_PROVIDER: "openclaw_nemo"
AI_FALLBACK_ORDER: '["openclaw_nemo","gemini","ollama","claude"]'
TOOL_CALLING_FALLBACK_ORDER: '["nemotron","gemini","claude"]'

# 絞殺者開關 (Phase B1-B2)
USE_AI_ROUTER: "false"            # false=舊路徑, true=新 AIRouter

# ============ Action Layer: MCP 開關 (不動) ============
# (保持現有 MCP 架構不動)
```

**即時切換指令:**
```bash
# 關閉 Gemini (省錢)
kubectl set env deployment/awoooi-api -n awoooi-prod ENABLE_GEMINI=false

# 切換主要 Provider
kubectl set env deployment/awoooi-api -n awoooi-prod AI_PRIMARY_PROVIDER=ollama

# 啟用新 AIRouter
kubectl set env deployment/awoooi-api -n awoooi-prod USE_AI_ROUTER=true
```

---

## 絞殺者遷移計畫

嚴格遵循 ADR-047 已驗證的四階段模式。

### Phase A: 基礎建設 (4h)

| 步驟 | 內容 | 時間 |
|------|------|------|
| A1 | `interfaces.py` — AIProvider Protocol + AIResult | 30m |
| A2 | `router.py` — AIRouter (Cache + 閘門 + 路由 + Trace) | 2h |
| A3 | `providers/openclaw_nemo.py` — 從 _call_openclaw_analyze 搬出 | 30m |
| A4 | `providers/gemini.py` — 從 _call_gemini 搬出 | 30m |
| A5 | `providers/ollama.py` — 從 _call_ollama 搬出 | 30m |

### Phase B1: 絞殺者包裝 (2h)

```python
# openclaw.py — 絞殺者分支
async def _call_with_fallback(self, prompt, ...):
    if settings.USE_AI_ROUTER:
        # 新路徑: AIRouter 統一管理
        from src.services.ai.router import get_ai_router
        router = get_ai_router()
        result = await router.route(prompt, context=alert_context)
        return result.raw_response, result.provider, result.success, result.tokens, result.cost_usd
    else:
        # 舊路徑: 原有 if/else chain (不動)
        for provider in settings.AI_FALLBACK_ORDER:
            ...
```

### Phase B2: 觀察期 (48h)

| 檢查項 | 工具 | 通過條件 |
|--------|------|---------|
| Langfuse Trace 完整 | Langfuse UI | 新舊路徑 Trace 結構一致 |
| Telegram 通知正確 | Telegram | Provider 名稱 + 信心度正確 |
| 錯誤率無增加 | SignOz | Error rate ≤ 舊版 |
| 延遲無惡化 | SignOz | P99 latency ≤ 舊版 +10% |

**回滾條件:** 任一項不通過 → `kubectl set env ... USE_AI_ROUTER=false`

### Phase B3: 剩餘 Provider (3h)

| 步驟 | 內容 |
|------|------|
| B3.1 | `providers/claude.py` — 從 _call_claude 搬出 |
| B3.2 | `providers/nvidia_nemotron.py` — 整合 nvidia_provider.py |
| B3.3 | `health.py` — AI Provider 健康檢查 |

### Phase B4: 封存舊代碼 (1h)

```bash
# 移除 openclaw.py 中的 _call_ollama/_call_gemini/_call_claude
# 移除 _call_with_cache 中的舊 fallback chain
# 驗證瘦身至 ~800 行
```

### Phase C: 動態控制面板 (4h)

| 步驟 | 內容 |
|------|------|
| C1 | Redis-backed `_is_provider_enabled()` — Redis 優先，env fallback |
| C2 | Telegram `/ai` command handler (status/enable/disable/primary/cost) |
| C3 | COMMANDER_CHAT_ID 白名單權限控制 |

```
/ai status     → 顯示所有 Provider 狀態 + 健康 + 費用
/ai enable X   → 啟用 Provider (寫 Redis)
/ai disable X  → 停用 Provider (寫 Redis)
/ai primary X  → 切換主要 Provider
/ai cost       → 本月 Token/費用統計
```

**權限:** 僅 `COMMANDER_CHAT_ID` 白名單可操作 (Tier 3 操作)

---

## 閘門執行順序

```
請求進來
  │
  ├─① MOCK_MODE? ──→ 直接回傳 (不進閘門)
  │
  ├─② Cache Hit? ──→ 回傳快取 (記錄 provider="cache")
  │
  ├─③ 建構 Provider 順序 (隱私 → Complexity → Fallback)
  │
  └─④ 遍歷 Provider:
       │
       ├─ Circuit Breaker — is_open()? → skip (0ms)
       │
       ├─ Rate Limiter — check_and_increment()? → skip (~1ms Redis)
       │
       ├─ Semaphore — 並發上限 → 排隊等待
       │
       ├─ provider.analyze() — 實際 AI 呼叫 (100ms-45s)
       │
       ├─ 成功 → record_success + record_cost + write_cache → 回傳
       │
       └─ 失敗 → record_failure → 繼續下一個 Provider
```

---

## 隱私規則 (零信任)

| Provider | privacy_level | 可處理 DIAGNOSE? | 可處理 CODE_REVIEW? |
|----------|--------------|-----------------|-------------------|
| Ollama | `local` | ✅ | ✅ |
| OpenClaw Nemo (NIM) | `cloud` | ❌ | ❌ |
| Gemini | `cloud` | ❌ | ❌ |
| Claude | `cloud` | ❌ | ❌ |
| Nemotron | `cloud` | ❌ | ❌ |

> **首席架構師裁示 (Q2):** NVIDIA NIM 即使有企業協議，在架構定義上仍屬 cloud。
> 未來若在內網自建 NIM 伺服器，只需將其 privacy_level 改為 local。

---

## 與現有 ADR 的關係

| ADR | 關係 | 整合方式 |
|-----|------|---------|
| **ADR-006** | AI Fallback Strategy | AIRouter 取代 if/else chain，保留 Fallback 順序概念 |
| **ADR-015** | MCP 模組化架構 | **直接複製** ProviderRegistry 模式到 Brain Layer |
| **ADR-017** | LLMOps Observability | Router 建立 Langfuse parent Trace，Provider 寫 child |
| **ADR-023** | Smart Routing | Intent/Complexity 保留為可選參數，Phase A 退化為靜態 |
| **ADR-024** | API Layer Architecture | AIRouter 屬 Service Layer，符合四層架構 |
| **ADR-036** | Nemotron Tool Calling | 獨立 Fallback Chain via `task_type="tool_calling"` |
| **ADR-044** | OpenClaw+Nemotron 協作 | Phase 22 Telegram 雙軌不受影響 |
| **ADR-047** | Phase R 絞殺者審查 | 嚴格遵循已驗證的 Strangler Fig 四階段 |

---

## 不受影響的路徑

以下路徑**完全不納入 AIRouter**，保持獨立：

| 路徑 | 原因 |
|------|------|
| **ConsensusEngine** (P0/P1) | 多 Agent 共識 ≠ 單 LLM 路由 (Claude Agent SDK) |
| **OpenClaw 委派** (188→NIM) | 這是一個 Provider，不是路由邏輯 |
| **MCP Bridge** (Action Layer) | 完全不同的層，已有自己的 Registry |

---

## 風險與緩解

| 風險 | 嚴重度 | 緩解措施 |
|------|--------|---------|
| 遷移期間 AI 仲裁中斷 | 高 | `USE_AI_ROUTER` 絞殺者開關，1 秒回滾 |
| Cache Key 不相容 | 中 | 新舊路徑共用相同 `llm_cache:{hash}` 格式 |
| Provider 介面設計不完整 | 低 | 先只支援 `analyze()`，Tool Calling 後續擴展 |
| 測試不足 | 低 | B2 觀察期 48h + Langfuse 100% Trace |
| openclaw.py 改壞 | 高 | B4 前完全不刪舊代碼，只新增 |

---

## 工時評估

| Phase | 內容 | 預估 |
|-------|------|------|
| A | Interface + AIRouter + 3 Provider | 4h |
| B1 | 絞殺者包裝 | 2h |
| B2 | 觀察期 | 48h |
| B3 | 剩餘 2 Provider + Health | 3h |
| B4 | 封存舊代碼 | 1h |
| C | Telegram/Redis 動態控制 | 4h |
| **總計** | | **~14h + 48h 觀察** |

---

## 驗收標準

| # | 項目 | 驗證方式 |
|---|------|---------|
| 1 | AIRouter 正確路由到 Primary Provider | Langfuse Trace 確認 |
| 2 | Fallback 降級正常 (Primary 失敗 → 次選) | 手動停 Ollama 驗證 |
| 3 | `ENABLE_*=false` 即時停用 Provider | kubectl set env 驗證 |
| 4 | DIAGNOSE Intent 強制走 local | Langfuse 不出現 cloud Provider |
| 5 | Cache 命中率不低於舊版 | Redis MONITOR 比對 |
| 6 | Telegram 顯示正確 Provider 名稱 | 實際告警驗證 |
| 7 | `USE_AI_ROUTER=false` 回滾正常 | 切換後服務不中斷 |
| 8 | Phase C: `/ai status` 回覆正確 | Telegram 驗證 |

---

## 參考

- ADR-015: MCP 模組化架構 (ProviderRegistry 模板)
- ADR-047: Phase R 絞殺者審查 (遷移驗證經驗)
- Memory: `project_phase24_ai_router.md`
- Memory: `feedback_strangler_fig_pattern.md`
- Memory: `feedback_lewooogo_modular_enforcement.md`
