"""
AI Router - Phase 13.3 #87
==========================
智能 AI 路由器,根據意圖和複雜度動態選擇 AI Provider
目標: 根據請求特性自動選擇最適模型
策略: Intent Classifier + Complexity Scorer → Routing Decision
延遲目標: < 50ms (規則引擎優先)
唯一 production 路由(所有風險、複雜度與工具路徑共用):
GCP-A Ollama -> GCP-B Ollama -> host111 Ollama -> Anthropic Claude API
-> Gemini API。Claude/Gemini 都必須通過 persistent enablement、原子成本閘與
per-provider circuit breaker;NVIDIA/Nemo/OpenClaw 僅保留 shadow metadata。
版本: v5.0
建立: 2026-03-26 (台北時區)
建立者: Claude Code
最後修改: 2026-07-14 (台北時區)
修改者: Codex
變更紀錄:
| 版本 | 日期 | 執行者 | 變更內容 |
|------|------|--------|----------|
| v1.0 | 2026-03-26 | Claude Code | 初始實作 |
| v2.0 | 2026-03-26 | Claude Code | 支援 IntentResult + 新意圖類型 |
| v3.0 | 2026-03-26 | Claude Code | Phase 13.3 #87 完整路由決策矩陣 |
| v4.0 | 2026-04-02 | ogt (首席架構師) | Phase 24 AIProvider Registry + Executor; C1 Langfuse Trace; C2 AIRouter.route(); C3 型別 typo; I4 Protocol close |
| v4.1 | 2026-04-04 | ogt (首席架構師) | Phase 25 P0: DIAGNOSE Privacy-First — _local_fallback_chain; DIAGNOSE→NEMOTRON; REJECT+Telegram |
| v4.2 | 2026-04-04 | Claude Code | Phase 25 P0 實測修正: _local_fallback_chain 移除 Nemotron(雲端),僅留 Ollama(本地); timeout 依實測調整(NIM 60s/Ollama 200s) |
| v4.3 | 2026-04-05 | Claude Code | Phase 25 P0 架構修正: 實測 Ollama CPU ~238s(不可用); NIM 實測 2-27s avg 10.6s; DIAGNOSE 改走 _full_fallback_chain(NIM 主力); _local_fallback_chain 廢棄 |
| v4.4 | 2026-04-27 | Claude Sonnet 4.6 | A2 INC-20260425: DIAGNOSE fallback chain 移除 Ollama (CPU 238s 二次 timeout); 新增 _diagnose_fallback_chain (NEMO→GEMINI→CLAUDE); 新增 aiops_diagnose_fallback_total metric |
| v5.1 | 2026-07-15 | Codex | Claude 以 paid/cost/canary gate 插入 host111 與 Gemini 之間;legacy providers 維持 shadow metadata |
"""
from __future__ import annotations
import asyncio
import hashlib
import json as _json
import re
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import TYPE_CHECKING, Protocol
import httpx as _httpx
import structlog
if TYPE_CHECKING:
from src.services.intent_classifier import IntentResult
from src.core.config import get_settings
from src.services.ai_provider_policy import (
NON_EXECUTABLE_SHADOW_PROVIDERS,
PAID_PROVIDER_ORDER,
PRODUCTION_OLLAMA_ORDER,
PRODUCTION_PROVIDER_ORDER,
SANITIZED_CLOUD_PROVIDER_ORDER,
normalize_production_execution_order,
)
from src.services.ai_providers.interfaces import AIProvider as AIProviderProtocol
from src.services.ai_providers.interfaces import AIResult
from src.services.complexity_scorer import (
ComplexityScore,
get_complexity_scorer,
)
from src.services.intent_classifier import (
IntentResult,
IntentType,
RiskLevel,
get_intent_classifier,
normalize_intent,
)
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
_DURABLE_OLLAMA_UNAVAILABLE_RECEIPT_SCHEMA = "ollama_unavailable_receipt_v1"
_DURABLE_OLLAMA_UNAVAILABLE_RECEIPT_PREFIX = "ai:ollama:unavailable_receipt:"
_DURABLE_OLLAMA_UNAVAILABLE_RECEIPT_ID = re.compile(r"^[A-Za-z0-9:_-]{1,128}$")
_DURABLE_OLLAMA_UNAVAILABLE_MAX_SECONDS = 300.0
def _is_paid_provider_identity(provider: object) -> bool:
"""Treat decorated paid identities as paid and never cache-executable."""
normalized = str(provider or "").strip().lower()
return any(
normalized == paid
or normalized.startswith(f"{paid}_")
or normalized.startswith(f"{paid}:")
or normalized.startswith(f"{paid}-")
for paid in PAID_PROVIDER_ORDER
)
# =============================================================================
# Provider 定義
# =============================================================================
class AIProviderEnum(str, Enum):
"""AI 提供者"""
OLLAMA = "ollama"
# 2026-05-04 ogt + Claude Sonnet 4.6: ADR-110 GCP 三層容災
# OllamaFailoverManager 回傳 provider_name="ollama_gcp_a"/"ollama_gcp_b"/"ollama_local"
# 缺少 enum 值 → AIProviderEnum(primary_str) 拋 ValueError → fallback chain 清空 → 直跳 Gemini
OLLAMA_GCP_A = "ollama_gcp_a" # GCP-A 34.143.170.20 Primary
OLLAMA_GCP_B = "ollama_gcp_b" # GCP-B 34.21.145.224 Secondary
OLLAMA_LOCAL = "ollama_local" # 192.168.0.111 Local Fallback
GEMINI = "gemini"
CLAUDE = "claude"
# 2026-04-02 ogt: C1 修復 — 對齊 Registry 實際名稱
# OpenClawNemoProvider.name = "openclaw_nemo" (一般推理, via .188)
# NemotronProvider.name = "nemotron" (Tool Calling, direct NVIDIA NIM)
# 舊版 NVIDIA = "nvidia" 已移除: Registry 無此 Provider
OPENCLAW_NEMO = "openclaw_nemo"
NEMOTRON = "nemotron"
# Provider 對應延遲預算 (ms)
PROVIDER_LATENCY_BUDGET: dict[AIProviderEnum, int] = {
AIProviderEnum.OLLAMA: 60000, # 本地,允許較長處理時間
# 2026-05-04 ogt: ADR-110 GCP 三層容災 — GCP NVMe SSD 推理快,60s 足夠
AIProviderEnum.OLLAMA_GCP_A: 60000,
AIProviderEnum.OLLAMA_GCP_B: 60000,
AIProviderEnum.OLLAMA_LOCAL: 90000, # 111 本地 HDD 稍慢
AIProviderEnum.GEMINI: 30000, # 雲端,較低延遲
AIProviderEnum.CLAUDE: 30000, # 雲端,較低延遲
# 2026-04-02 ogt: C1 修復 — 對齊 Registry 名稱
AIProviderEnum.OPENCLAW_NEMO: 60000, # via .188 → NVIDIA NIM,允許較長時間
AIProviderEnum.NEMOTRON: 60000, # Tool Calling 專用,允許較長時間
}
# =============================================================================
# Interface 定義 (P1 修復 - 2026-04-01 首席架構師審查)
# =============================================================================
class IAIRouter(Protocol):
"""
AI Router Protocol - 支援 DI 測試替換
2026-04-01 ogt: 首席架構師審查 P1 修復
- 新增 Protocol 定義支援依賴注入
- 參考: IModelRegistry, IComplexityScorer
"""
async def route(
self,
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""路由請求到最適 AI Provider"""
...
def route_sync(
self,
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""同步版本路由"""
...
def route_tool_calling(
self,
) -> tuple[AIProviderEnum, str, list[tuple[AIProviderEnum, str]]]:
"""Tool Calling 專用路由"""
...
@dataclass
class RoutingDecision:
"""
路由決策結果 (Phase 13.3 #87)
包含完整的路由資訊,供 OpenClaw 主流程使用
"""
# 核心決策
selected_provider: AIProviderEnum # 選擇的 AI Provider
selected_model: str # 選擇的模型名稱
fallback_chain: list[tuple[AIProviderEnum, str]] # 備援鏈 [(provider, model), ...]
routing_reason: str # 路由決策原因
latency_budget_ms: int # 延遲預算 (毫秒)
# 分類結果
intent: IntentType # 意圖分類 (正規化後)
intent_result: IntentResult # 完整 Intent 分類結果
complexity: ComplexityScore # 複雜度評分
risk_level: RiskLevel = field(default=RiskLevel.MEDIUM) # 風險等級
observed_fallback_provider: str | None = None
# 路由 metadata
routing_latency_ms: float = 0.0 # 路由決策耗時 (ms)
# 向後相容 (deprecated)
model: str = "" # -> selected_model
reason: str = "" # -> routing_reason
fallback_models: list[str] = field(default_factory=list) # -> fallback_chain
def __post_init__(self):
"""初始化後設定衍生欄位"""
self.risk_level = self.intent_result.risk_level
# 向後相容
self.model = self.selected_model
self.reason = self.routing_reason
self.fallback_models = [model for _, model in self.fallback_chain if model != self.selected_model]
def to_dict(self) -> dict:
"""轉換為字典 (API 回應用)"""
return {
"selected_provider": self.selected_provider.value,
"selected_model": self.selected_model,
"fallback_chain": [
{"provider": p.value, "model": m} for p, m in self.fallback_chain
],
"routing_reason": self.routing_reason,
"latency_budget_ms": self.latency_budget_ms,
"intent": self.intent.value,
"risk_level": self.risk_level.value,
"complexity_score": self.complexity.score,
"routing_latency_ms": round(self.routing_latency_ms, 2),
"production_route_contract": list(PRODUCTION_PROVIDER_ORDER),
"selection_mode": "fixed_production_order",
"observed_fallback_provider": self.observed_fallback_provider,
}
class AIRouter:
"""
AI 路由器 (Phase 13.3 #87)
整合 IntentClassifier 和 ComplexityScorer,
動態選擇最適合的 AI Provider 和模型。
Intent/risk/complexity only choose an Ollama model. They never choose a
provider. Provider placement is the global five-hop production contract.
"""
def __init__(self):
self._intent_classifier = get_intent_classifier()
self._complexity_scorer = get_complexity_scorer()
self._model_registry = get_model_registry()
# 2026-04-25 P1.2 by Claude Engineer-A2 — failover 整合到 ai_router + lifespan
# 延遲 import 避免循環依賴(ollama_failover_manager 不 import ai_router)
from src.services.ollama_failover_manager import get_ollama_failover_manager
self._failover_manager = get_ollama_failover_manager()
# 從 ModelRegistry 取得模型配置
self._ollama_default = self._model_registry.get_model("ollama", "default")
self._ollama_summary = self._model_registry.get_model("ollama", "summary")
self._gemini_default = self._model_registry.get_model("gemini", "default")
self._claude_default = self._model_registry.get_model("claude", "default")
# 2026-04-02 ogt: C1 修復 — openclaw_nemo (一般推理) + nemotron (Tool Calling)
self._openclaw_nemo_default = self._model_registry.get_model("nvidia", "default")
self._nemotron_default = self._model_registry.get_model("nvidia", "default")
# 向後相容別名
self._nvidia_default = self._openclaw_nemo_default
# Provider 對應模型映射
self._provider_models: dict[AIProviderEnum, str] = {
AIProviderEnum.OLLAMA: self._ollama_default,
AIProviderEnum.GEMINI: self._gemini_default,
AIProviderEnum.CLAUDE: self._claude_default,
AIProviderEnum.OPENCLAW_NEMO: self._openclaw_nemo_default,
AIProviderEnum.NEMOTRON: self._nemotron_default,
}
# 唯一 production chain。舊 provider 仍可保留在 registry 供 replay /
# shadow metadata,但不得出現在任何 executable fallback chain。
self._full_fallback_chain: list[tuple[AIProviderEnum, str]] = [
(AIProviderEnum.OLLAMA_GCP_A, self._ollama_default),
(AIProviderEnum.OLLAMA_GCP_B, self._ollama_default),
(AIProviderEnum.OLLAMA_LOCAL, self._ollama_default),
(AIProviderEnum.CLAUDE, self._claude_default),
(AIProviderEnum.GEMINI, self._gemini_default),
]
# 2026-04-29 ogt + Claude Code: 推翻 A2 鐵律(INC-20260425 事實基礎已過期)
# 統帥鐵律 2026-04-29: 「主要優先用 111 主機的 Ollama」
# 統帥鐵律 feedback_ai_autonomous_direction.md: 以本地免費 LLM 為主
# 統帥鐵律 feedback_ollama_111_only.md: Ollama 唯一主機 = 111
#
# 推翻原因:
# 舊事實 (2026-04-27 A2): Ollama = CPU-only deepseek-r1:14b @ 238s(不可用)
# 新事實 (2026-04-29): prod Ollama 111 = M1 Pro Apple Silicon GPU + qwen2.5:7b-instruct
# VRAM 8.2GB 全載入,ctx 32k,實測 hi 0.54s
# 雲端全死: OpenClaw 188 → 500,Gemini → 429 配額爆,Claude → 404 endpoint 過時
# 不推翻 → 100% incident llm_failed → AI 自動修復永遠不啟動
#
# 配套修改:
# - IntentType.DIAGNOSE override: OPENCLAW_NEMO → OLLAMA
# - openclaw.py 注入 task_type="diagnose" 讓 Ollama 用 200s timeout
# - test_p0_diagnose_routing.py / test_ai_router_diagnose_fallback.py 同步更新
self._diagnose_fallback_chain = self._full_fallback_chain.copy()
# Tool Calling 專用 Fallback 鏈 (ADR-036)
self._tool_calling_fallback_chain = self._full_fallback_chain.copy()
# 2026-04-05 Claude Code: Phase 25 P0 v4.3 — _local_fallback_chain 廢棄
# 實測依據 (2026-04-05):
# Ollama llama3.2:3b CPU-only = 238s 回 {"ok":true}(完全不可用於生產)
# Nemotron NIM 實測 2.2s~27s,平均 10.6s(雲端 GPU,一直是主力)
# NIM 從 Phase 22 起就接收 Incident 資料(無隱私問題,非新決策)
# 結論: 不存在可用的本地 AI provider,DIAGNOSE 統一走 _full_fallback_chain(NIM 主力)
self._local_fallback_chain: list[tuple[AIProviderEnum, str]] = [
# 廢棄: Ollama CPU ~238s 不可用,NIM 本非 local。保留欄位避免 attribute error。
]
# 意圖對應 Provider 強制覆寫 (None = 依複雜度決定)
self._intent_provider_overrides: dict[IntentType, AIProviderEnum | None] = {
# 四大核心意圖
IntentType.RESTART: None, # 依複雜度
IntentType.SCALE: None, # 依複雜度
IntentType.CONFIG: None, # 依複雜度 (但 HIGH 會升級)
# 2026-04-16 ogt: 恢復 DIAGNOSE → OPENCLAW_NEMO(已過期,見 2026-04-29 推翻)
# 2026-04-29 ogt + Claude Code: 推翻 A2,DIAGNOSE → OLLAMA(統帥鐵律本地優先)
# 舊事實過期:M1 Pro GPU + qwen2.5:7b 0.54s(不再 CPU deepseek 238s)
# OPENCLAW_NEMO 188:8088 現況 500 → 不可用
# 雲端全死 → 必須回到本地 Ollama 主推理
IntentType.DIAGNOSE: AIProviderEnum.OLLAMA,
# 輔助意圖
IntentType.DELETE: AIProviderEnum.OLLAMA,
IntentType.ROLLBACK: None, # 依複雜度
IntentType.UNKNOWN: None,
# 舊版兼容
IntentType.CODE_REVIEW: None,
IntentType.DEPLOYMENT: None,
IntentType.ALERT_TRIAGE: AIProviderEnum.OLLAMA,
IntentType.QUERY: AIProviderEnum.OLLAMA,
IntentType.MAINTENANCE: None,
}
# 向後相容
self._default_model = self._ollama_default
self._summary_model = self._ollama_summary
self._fallback_order = [self._ollama_default, self._gemini_default]
def _resolve_intent_from_context(
self,
context: dict | None,
) -> IntentResult | None:
"""
從 context 解析集中治理的 intent hint。
僅作為 AI Router 的內部快路徑,避免呼叫端自行繞過 Router 規則。
例如 Phase 2 agent 已知屬於診斷分析,就不必再多跑一次 intent LLM。
"""
if not context:
return None
raw_hint = str(context.get("intent_hint", "")).strip().lower()
if not raw_hint:
return None
alias_map = {
"restart": IntentType.RESTART,
"scale": IntentType.SCALE,
"config": IntentType.CONFIG,
"diagnose": IntentType.DIAGNOSE,
"delete": IntentType.DELETE,
"rollback": IntentType.ROLLBACK,
"unknown": IntentType.UNKNOWN,
# legacy aliases
"alert_triage": IntentType.ALERT_TRIAGE,
"deployment": IntentType.DEPLOYMENT,
"query": IntentType.QUERY,
"maintenance": IntentType.MAINTENANCE,
"code_review": IntentType.CODE_REVIEW,
}
intent = alias_map.get(raw_hint)
if intent is None:
logger.warning("ai_router_invalid_intent_hint", intent_hint=raw_hint)
return None
return IntentResult(
intent=intent,
confidence=1.0,
method="context_hint",
matched_keywords=[f"context:{raw_hint}"],
detected_resources=[],
reasoning=f"context intent_hint={raw_hint}",
)
async def route(
self,
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""
路由請求到最適 AI Provider 和模型
延遲目標: < 50ms (規則引擎優先,LLM 分類時可能稍長)
Args:
text: 用戶輸入或告警內容
context: 額外上下文 (服務、指標等)
Returns:
RoutingDecision: 完整路由決策
"""
start_time = time.perf_counter()
context = context or {}
# Step 1: 意圖分類 (返回 IntentResult, 規則引擎 < 10ms)
intent_result = self._resolve_intent_from_context(context)
if intent_result is None:
intent_result = await self._intent_classifier.classify(text)
intent = normalize_intent(intent_result.intent)
# Step 2: 複雜度評分 (< 10ms)
complexity = self._complexity_scorer.score(context)
# Step 3: Provider + Model 選擇 (< 1ms)
provider, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
# Step 3b: collect failover health evidence without allowing the probe
# result to reorder or truncate the executable production chain. A
# health-selected Gemini primary previously let AIRouter jump directly
# to the paid provider without attempting GCP-A/GCP-B/host111. Runtime
# execution is now always normalized to the concrete first hop; the
# executor owns bounded failover and Gemini's atomic cost gate.
observed_fallback_provider: str | None = None
if provider == AIProviderEnum.OLLAMA:
try:
failover_result = await self._failover_manager.select_provider(
task_type=(
"alert_fast"
if intent == IntentType.ALERT_TRIAGE
else intent.value if intent else "general"
),
emit_side_effects=True,
)
observed_fallback = getattr(
failover_result,
"observed_fallback",
None,
)
observed_fallback_provider = (
str(observed_fallback.provider_name)
if observed_fallback
else None
)
reason = (
f"{reason} [selected primary=ollama_gcp_a; "
f"observed fallback={observed_fallback_provider or 'none'}; "
"execution order remains fixed]"
)
except Exception as e:
# Health evidence is best-effort for free lanes. Failure to
# probe cannot authorize a paid-provider jump.
logger.warning("ai_router_failover_manager_error", error=str(e))
provider = AIProviderEnum.OLLAMA_GCP_A
# Step 4: 建立 Fallback 鏈
# 2026-04-05 Claude Code: v4.3 — NIM 從 Phase 22 起就是主力,無隱私問題
# 2026-04-25 P1.2: 若 failover_manager 回傳了 fallback chain,優先使用
# 2026-04-27 Claude Sonnet 4.6: A2 INC-20260425 — DIAGNOSE 專用 chain(排除 Ollama)
# failover_manager 只在 OLLAMA 路徑觸發(Step 3b 限制),DIAGNOSE→OPENCLAW_NEMO
# 不會進入 failover 路徑,因此 fallover_fallback 此時為 None,走 _build_fallback_chain_for_intent
fallback_chain = self._build_fallback_chain_for_intent(provider, intent)
# Step 5: 計算延遲預算
latency_budget = PROVIDER_LATENCY_BUDGET.get(provider, 30000)
# 計算路由決策耗時
routing_latency = (time.perf_counter() - start_time) * 1000
decision = RoutingDecision(
selected_provider=provider,
selected_model=model,
fallback_chain=fallback_chain,
routing_reason=reason,
latency_budget_ms=latency_budget,
intent=intent,
intent_result=intent_result,
complexity=complexity,
routing_latency_ms=routing_latency,
observed_fallback_provider=observed_fallback_provider,
)
logger.info(
"ai_routing_decision",
provider=provider.value,
model=model,
intent=intent.value,
intent_confidence=intent_result.confidence,
risk_level=intent_result.risk_level.value,
complexity_score=complexity.score,
reason=reason,
latency_budget_ms=latency_budget,
routing_latency_ms=round(routing_latency, 2),
fallback_count=len(fallback_chain),
observed_fallback_provider=observed_fallback_provider,
)
return decision
def _select_provider_and_model(
self,
intent: IntentType,
intent_result: IntentResult,
complexity: ComplexityScore,
) -> tuple[AIProviderEnum, str, str]:
"""
選擇 Provider 和模型 (Phase 13.3 #87 核心邏輯)
所有 intent / risk / complexity 只選擇 Ollama model;provider
execution order 永遠是 GCP-A、GCP-B、host111、Claude,最後才是 Gemini。
Args:
intent: 正規化後的意圖
intent_result: 完整分類結果
complexity: 複雜度評分
Returns:
(provider, model, reason)
"""
risk = intent_result.risk_level
score = complexity.score
# Intent overrides may choose only the logical Ollama lane. Risk and
# complexity can select a heavier Ollama model, never another provider.
provider_override = self._intent_provider_overrides.get(intent)
if provider_override is not None:
provider = AIProviderEnum.OLLAMA
if intent in (
IntentType.ALERT_TRIAGE, IntentType.QUERY
):
model = self._ollama_summary
else:
model = self._ollama_default
reason = (
f"意圖 {intent.value} 使用全域 production chain;"
"provider 起點固定 GCP-A Ollama"
)
return provider, model, reason
provider = AIProviderEnum.OLLAMA
model = self._ollama_summary if score <= 1 else self._ollama_default
reason = (
f"複雜度={score}/5, 風險={risk.value};全域 production chain "
"GCP-A -> GCP-B -> host111 -> Claude -> Gemini"
)
return provider, model, reason
def _select_model(
self,
intent: IntentType,
intent_result: IntentResult,
complexity: ComplexityScore,
) -> tuple[str, str]:
"""
選擇模型 (向後相容方法)
Deprecated: 請使用 _select_provider_and_model
Args:
intent: 正規化後的意圖
intent_result: 完整分類結果
complexity: 複雜度評分
Returns:
(model_name, reason)
"""
_, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
return model, reason
def _build_fallback_chain(
self, selected_provider: AIProviderEnum
) -> list[tuple[AIProviderEnum, str]]:
"""
# DEPRECATED 2026-04-28 — 已由 _build_fallback_chain_for_intent 取代,無呼叫方
建立 Fallback 鏈 (排除已選 Provider)
Fallback 順序: GCP-A → GCP-B → host111 → Claude → Gemini
Args:
selected_provider: 已選擇的 Provider
Returns:
Fallback 鏈 [(provider, model), ...]
"""
return self._build_fallback_chain_for_intent(
selected_provider,
IntentType.UNKNOWN,
)
def _build_fallback_list(self, selected_model: str) -> list[str]:
"""建立 Fallback 列表 (向後相容)"""
fallbacks = [m for m in self._fallback_order if m != selected_model]
return fallbacks
def _build_fallback_chain_for_intent(
self,
selected_provider: AIProviderEnum,
intent: IntentType,
) -> list[tuple[AIProviderEnum, str]]:
"""
建構單一 production fallback 鏈,排除已選的第一跳。
intent 參數僅為介面相容,不得改寫 provider order。
Args:
selected_provider: 已選擇的 primary Provider
intent: 正規化後的意圖
Returns:
Fallback 鏈 [(provider, model), ...],排除 selected_provider
"""
_ = intent
canonical_selected = (
AIProviderEnum.OLLAMA_GCP_A
if selected_provider == AIProviderEnum.OLLAMA
else selected_provider
)
providers = [provider for provider, _model in self._full_fallback_chain]
if canonical_selected not in providers:
return self._full_fallback_chain.copy()
selected_index = providers.index(canonical_selected)
return self._full_fallback_chain[selected_index + 1 :]
def route_sync(
self,
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""
同步版本路由 (僅關鍵字匹配,保證 < 50ms)
適用場景: 需要快速決策,不需要 LLM 分類的情況
Args:
text: 用戶輸入或告警內容
context: 額外上下文
Returns:
RoutingDecision: 路由決策
"""
start_time = time.perf_counter()
context = context or {}
# 同步分類 (僅規則引擎, < 10ms)
intent_result = self._resolve_intent_from_context(context)
if intent_result is None:
intent_result = self._intent_classifier.classify_sync(text)
intent = normalize_intent(intent_result.intent)
# 複雜度評分 (< 10ms)
complexity = self._complexity_scorer.score(context)
# Provider + Model 選擇
provider, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
# route_sync cannot run health probes, so publish the concrete first hop
# rather than the legacy logical ``ollama`` alias.
if provider == AIProviderEnum.OLLAMA:
provider = AIProviderEnum.OLLAMA_GCP_A
reason = f"{reason} [sync concrete first hop=ollama_gcp_a]"
# 建立固定 production fallback 鏈。
fallback_chain = self._build_fallback_chain_for_intent(provider, intent)
# 延遲預算
latency_budget = PROVIDER_LATENCY_BUDGET.get(provider, 30000)
# 計算路由決策耗時
routing_latency = (time.perf_counter() - start_time) * 1000
return RoutingDecision(
selected_provider=provider,
selected_model=model,
fallback_chain=fallback_chain,
routing_reason=reason,
latency_budget_ms=latency_budget,
intent=intent,
intent_result=intent_result,
complexity=complexity,
routing_latency_ms=routing_latency,
)
# =========================================================================
# Tool Calling 路由 (ADR-036)
# =========================================================================
def route_tool_calling(self) -> tuple[AIProviderEnum, str, list[tuple[AIProviderEnum, str]]]:
"""
Tool Calling 使用同一 production 路由;Nemotron 僅保留 shadow metadata。
Returns:
(provider, model, fallback_chain)
"""
provider = AIProviderEnum.OLLAMA_GCP_A
model = self._model_registry.get_model("ollama", "trust_scoring")
fallback_chain = [
(p, m) for p, m in self._tool_calling_fallback_chain if p != provider
]
logger.info(
"tool_calling_routing",
provider=provider.value,
model=model,
fallback_count=len(fallback_chain),
)
return provider, model, fallback_chain
def get_tool_calling_fallback_chain(self) -> list[tuple[AIProviderEnum, str]]:
"""取得 Tool Calling Fallback 鏈"""
return self._tool_calling_fallback_chain.copy()
# =========================================================================
# 便捷方法
# =========================================================================
def get_provider_for_intent(self, intent: IntentType) -> AIProviderEnum:
"""取得意圖對應的 Provider (不考慮複雜度)"""
_ = intent
return AIProviderEnum.OLLAMA_GCP_A
def get_model_for_provider(self, provider: AIProviderEnum) -> str:
"""取得 Provider 對應的模型"""
return self._provider_models.get(provider, self._ollama_default)
def get_routing_matrix(self) -> list[dict]:
"""
取得路由決策矩陣 (用於 API 文檔或除錯)
Returns:
路由規則清單
"""
return [
{
"rule": 1,
"condition": "all intents, risks, complexities, tools and background jobs",
"provider": "ollama_gcp_a",
"provider_order": list(PRODUCTION_PROVIDER_ORDER),
"reason": "single global production route; paid providers require cost gates and legacy providers remain shadow metadata only",
}
]
async def feedback_from_aider_events(
self,
repo: str | None = None,
days: int = 7,
) -> dict[str, float]:
"""從 aider_events 聚合近 N 日 success rate per model。
Phase 24 ADR-052 延伸:AI 自主化 feedback loop。
目前為 read-only(未接入 route() 決策),等 USE_AIDER_FEEDBACK flag
+ 7 天灰度驗證後才會調整 provider 權重。
Args:
repo: 若給定,只聚合該 repo 的 session;否則所有 repo。
days: 時間窗口(預設 7 天)。
Returns:
{model_name: success_rate_float} e.g. {"elephant-alpha": 0.85, "gemini-pro": 0.92}
空 dict 代表無資料或查詢失敗(caller 應降級為忽略)。
"""
try:
from src.db.base import get_db_context
from src.repositories.aider_event_repository import AiderEventRepository
except ImportError:
return {}
try:
async with get_db_context() as sess:
repo_obj = AiderEventRepository(sess)
stats = await repo_obj.model_stats_since(days=days)
except Exception:
logger.debug("ai_router_feedback_aggregation_failed")
return {}
return self._aggregate_feedback_stats(stats, repo=repo)
@staticmethod
def _aggregate_feedback_stats(
stats: list[dict], repo: str | None = None
) -> dict[str, float]:
"""純函數:過濾 repo 並將 stats 轉換為 {model: success_rate}(可獨立單元測試)。"""
out: dict[str, float] = {}
for row in stats:
if repo and row.get("repo") != repo:
continue
model = row.get("model")
if not model:
continue
out[model] = float(row.get("success_rate") or 0)
return out
# =============================================================================
# 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()
# =============================================================================
_settings = get_settings()
class _SimpleCircuitBreaker:
"""
輕量 per-provider Circuit Breaker (Phase 24 C2 修復)
不共用 OpenClawGuard — 避免 Gemini 掛掉時 Ollama 也被擋
"""
def __init__(self, name: str, failure_threshold: int = 5, recovery_timeout: float = 60.0) -> None:
self.name = name
self._failure_threshold = failure_threshold
self._recovery_timeout = recovery_timeout
self._failure_count = 0
self._last_failure_time: float = 0.0
def is_open(self) -> bool:
if self._failure_count < self._failure_threshold:
return False
# 超過 recovery timeout → half-open (允許一次嘗試)
if time.time() - self._last_failure_time > self._recovery_timeout:
return False
return True
def record_success(self) -> None:
self._failure_count = 0
def record_failure(self) -> None:
self._failure_count += 1
self._last_failure_time = time.time()
def cooldown_remaining_seconds(self) -> int:
if self._failure_count < self._failure_threshold:
return 0
return max(
0,
int(self._recovery_timeout - (time.time() - self._last_failure_time)),
)
class AIProviderRegistry:
"""
AI Provider 註冊中心 — 類比 MCP ProviderRegistry (ADR-015)
動態管理 AI Provider 的生命週期與啟停狀態。
"""
def __init__(self) -> None:
self._providers: dict[str, AIProviderProtocol] = {}
self._shadow_providers: dict[str, AIProviderProtocol] = {}
def register(self, provider: AIProviderProtocol) -> None:
"""註冊 Provider (啟動時呼叫)"""
if provider.name in NON_EXECUTABLE_SHADOW_PROVIDERS:
self._shadow_providers[provider.name] = provider
logger.info(
"ai_provider_registered_shadow_metadata_only",
name=provider.name,
production_executable=False,
)
return
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 [*self._providers.keys(), *self._shadow_providers.keys()]
async def health_check_all(self) -> dict[str, bool]:
"""所有 Provider 健康狀態"""
results = {name: False for name in self._shadow_providers}
for name, p in self._providers.items():
try:
results[name] = await p.health_check()
except Exception:
results[name] = False
return results
async def close_all(self) -> None:
"""關閉所有 Provider 的 HTTP 連線 (I5 修復: shutdown hook)"""
for name, p in self._providers.items():
try:
if hasattr(p, "close"):
await p.close()
logger.info("ai_provider_closed", name=name)
except Exception as e:
logger.warning("ai_provider_close_failed", name=name, error=str(e))
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] = {}
# C2 修復: per-provider Circuit Breaker (不共用,避免一個掛全部擋)
self._circuit_breakers: dict[str, _SimpleCircuitBreaker] = {}
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]
def _get_circuit_breaker(self, name: str) -> _SimpleCircuitBreaker:
"""取得 Provider 的 Circuit Breaker (per-provider, lazy init)"""
if name not in self._circuit_breakers:
# 2026-04-05 Claude Code: v4.3 — NIM 使用更寬鬆的 CB 參數
# 每次都先跑 NIM,只有真正連線錯誤(非 timeout)才累積失敗
# failure_threshold=10: 需要 10 次真實錯誤才 OPEN(timeout 不計)
# recovery_timeout=30: 30s 後進入 half-open,立即重試 NIM
if name == "nemotron":
self._circuit_breakers[name] = _SimpleCircuitBreaker(
name, failure_threshold=10, recovery_timeout=30.0
)
elif name == "claude":
self._circuit_breakers[name] = _SimpleCircuitBreaker(
name, failure_threshold=3, recovery_timeout=120.0
)
else:
self._circuit_breakers[name] = _SimpleCircuitBreaker(name)
return self._circuit_breakers[name]
@staticmethod
def _asset_execution_preflight(context: dict | None) -> AIResult | None:
"""Reject unresolved identities and open asset circuits before any model call."""
context = context or {}
route = context.get("typed_target_route") or context.get(
"typedTargetRoute"
)
identity = context.get("canonical_asset_identity")
route = route if isinstance(route, dict) else {}
identity = identity if isinstance(identity, dict) else {}
resolution_values = {
str(value or "").strip().lower()
for value in (
route.get("resolution_status"),
identity.get("resolution_status"),
context.get("asset_identity_resolution_status"),
)
if str(value or "").strip()
}
canonical_asset_id = str(
route.get("canonical_asset_id")
or identity.get("canonical_id")
or context.get("canonical_asset_id")
or ""
).strip()
identity_unresolved = bool(
resolution_values
& {
"ambiguous",
"asset_identity_ambiguous",
"asset_identity_unresolved",
"unknown",
"unresolved",
}
) or bool(
"resolved" in resolution_values
and (route or identity)
and not canonical_asset_id
)
if identity_unresolved:
identity_seed = str(
context.get("target_resource") or "asset-identity-unresolved"
)
digest = hashlib.sha256(identity_seed.encode("utf-8")).hexdigest()
return AIResult(
raw_response="",
success=False,
provider="none",
error="asset_identity_unresolved",
audit_metadata={
"schema_version": "ai_asset_identity_fail_closed_v1",
"status": "asset_identity_unresolved",
"provider_network_call_performed": False,
"cross_provider_fallback_allowed": False,
"cross_domain_fallback_allowed": False,
"drift_work_item_id": f"AIA-ASSET-DRIFT-{digest[:12].upper()}",
},
)
circuit_state = str(
route.get("circuit_state")
or identity.get("circuit_state")
or context.get("asset_circuit_state")
or ""
).strip().lower()
circuit_open = circuit_state == "open" or any(
value is True
for value in (
route.get("circuit_open"),
identity.get("circuit_open"),
context.get("asset_circuit_open"),
)
)
if not circuit_open:
return None
asset_seed = canonical_asset_id or str(
context.get("target_resource") or "asset-circuit"
)
digest = hashlib.sha256(asset_seed.encode("utf-8")).hexdigest()
domain = str(
route.get("target_kind")
or identity.get("asset_domain")
or context.get("asset_domain")
or "unknown"
).strip().lower()
return AIResult(
raw_response="",
success=False,
provider="none",
error="asset_circuit_open_fail_closed",
audit_metadata={
"schema_version": "ai_asset_circuit_open_terminal_v1",
"status": "circuit_open",
"circuit_scope": "asset",
"asset_identity_sha256": digest,
"canonical_domain": domain,
"provider_network_call_performed": False,
"cross_provider_fallback_allowed": False,
"cross_domain_fallback_allowed": False,
"allowed_actions": [
"same_asset_domain_bounded_cooldown_probe",
"create_same_asset_domain_repair_work_item",
],
"repair_work_item": {
"work_item_id": f"AIA-CIRCUIT-ASSET-{digest[:16].upper()}",
"domain": domain,
"execution_allowed": False,
"required_next_receipt": "bounded_cooldown_or_repair_verifier",
},
},
)
@staticmethod
def _provider_circuit_open_result(
*,
provider_name: str,
circuit_breaker: _SimpleCircuitBreaker,
context: dict | None,
) -> AIResult:
work_item_seed = str((context or {}).get("work_item_id") or "")
digest = hashlib.sha256(
f"{provider_name}|{work_item_seed}".encode()
).hexdigest()
return AIResult(
raw_response="",
success=False,
provider="none",
error=f"provider_circuit_open_fail_closed:{provider_name}",
audit_metadata={
"schema_version": "ai_provider_circuit_open_terminal_v1",
"status": "circuit_open",
"circuit_scope": "provider",
"canonical_provider_lane": provider_name,
"provider_network_call_performed": False,
"cross_provider_fallback_allowed": False,
"cross_domain_fallback_allowed": False,
"cooldown_remaining_seconds": (
circuit_breaker.cooldown_remaining_seconds()
),
"allowed_actions": [
"same_provider_bounded_cooldown_probe",
"create_same_provider_repair_work_item",
],
"repair_work_item": {
"work_item_id": f"AIA-CIRCUIT-PROVIDER-{digest[:16].upper()}",
"provider": provider_name,
"execution_allowed": False,
"required_next_receipt": "same_provider_cooldown_verifier",
},
},
)
@staticmethod
async def _attach_gcp_transport_receipt(
*,
provider_name: str,
provider_context: dict | None,
) -> tuple[dict | None, str | None]:
"""Issue and attach one same-run GCP transport receipt fail-closed."""
if provider_name not in {"ollama_gcp_a", "ollama_gcp_b"}:
return provider_context, None
if not isinstance(provider_context, dict):
return None, "gcp_cloud_transport_context_missing"
from src.services.cloud_transport_receipt import (
bounded_cloud_transport_receipt_hints,
issue_cloud_transport_receipt,
resolve_gcp_transport_endpoint,
verify_cloud_transport_receipt,
)
endpoint_url = resolve_gcp_transport_endpoint(provider_name)
existing_reason = await verify_cloud_transport_receipt(
context=provider_context,
provider=provider_name,
endpoint_url=endpoint_url,
)
if existing_reason is None:
return provider_context, None
try:
receipt = await issue_cloud_transport_receipt(
trace_id=str(provider_context.get("trace_id") or ""),
run_id=str(provider_context.get("run_id") or ""),
work_item_id=str(provider_context.get("work_item_id") or ""),
provider=provider_name,
endpoint_url=endpoint_url,
)
except Exception as error:
logger.warning(
"ai_router_gcp_transport_preflight_failed",
provider=provider_name,
existing_reason=existing_reason,
error_type=type(error).__name__,
)
return None, "gcp_cloud_transport_preflight_failed"
receipts = bounded_cloud_transport_receipt_hints(
provider_context.get("cloud_transport_receipts")
)
receipts = [
item
for item in receipts
if str(item.get("provider") or "") != provider_name
]
receipts.append(receipt)
updated_context = dict(provider_context)
updated_context["cloud_transport_receipts"] = receipts
return updated_context, None
@staticmethod
def _cache_key(prompt: str, context: dict | None) -> str:
"""生成 Cache Key (與 openclaw.py 相容)"""
ctx_hash = ""
if context:
cloud_context = context.get("paid_cloud_context") or {}
receipt = (
cloud_context.get("cloud_sanitization_receipt") or {}
if isinstance(cloud_context, dict)
else {}
)
ctx_hash = (
f":{context.get('alert_type', '')}:{context.get('target_resource', '')}:"
f"{(cloud_context or {}).get('data_classification', context.get('data_classification', ''))}:"
f"{receipt.get('receipt_id', '') if isinstance(receipt, dict) else ''}"
)
content = f"{prompt}{ctx_hash}"
return f"llm_cache:{hashlib.sha256(content.encode()).hexdigest()[:16]}"
@staticmethod
def _sre_chat_sanitization_receipt_block_reason(
prompt: str,
context: dict | None,
) -> str | None:
"""Verify the formal prompt-bound receipt for real SRE conversation."""
root = context or {}
receipt = root.get("sre_chat_sanitization_receipt")
if not isinstance(receipt, dict):
# Preserve the established generic cloud boundary code for callers
# that provide neither an alert nor a real-chat receipt.
return "cloud_sanitization_receipt_missing"
prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
if receipt.get("prompt_sha256") != prompt_sha256:
return "sre_chat_sanitized_prompt_mismatch"
correlation_fields = ("trace_id", "run_id", "work_item_id")
if (
receipt.get("schema_version")
!= "sre_chat_sanitization_receipt_v1"
or receipt.get("status") != "verified"
or receipt.get("sanitizer")
!= "src.services.sanitization_service.sanitize"
or receipt.get("source_label") != "awoooi_sre_group_chat"
or receipt.get("data_classification") != "sanitized"
or receipt.get("raw_input_forwarded") is not False
or receipt.get("raw_log_payload_forwarded") is not False
or receipt.get("secret_value_exposed") is not False
or receipt.get("private_network_value_exposed") is not False
or receipt.get("infrastructure_remediation_write_allowed") is not False
or receipt.get("provider_timeout_seconds") != 35
or root.get("data_classification") != "sanitized"
or root.get("infrastructure_remediation_write_allowed") is not False
or root.get("provider_timeout_seconds") != 35
or root.get("executor_invocation_allowed") is not False
or root.get("agent99_dispatch_allowed") is not False
or root.get("cross_domain_execution_allowed") is not False
or any(
not str(root.get(field) or "")
or receipt.get(field) != root.get(field)
for field in correlation_fields
)
):
return "sre_chat_sanitization_receipt_invalid"
return None
@staticmethod
def _paid_alert_execution_inputs(
prompt: str,
context: dict | None,
) -> tuple[str | None, dict | None, str | None]:
"""Validate an allowlist sanitizer receipt and isolate cloud inputs."""
is_alert = bool(context) and any(
key in context
for key in ("alert_type", "alertname", "alert_name", "incident_id", "signals")
)
if not is_alert:
root = context or {}
if root.get("synthetic_validation") is True:
if (
root.get("data_classification") == "sanitized"
and root.get("infrastructure_remediation_write_allowed") is False
and root.get("synthetic_prompt_sha256")
== hashlib.sha256(prompt.encode("utf-8")).hexdigest()
):
return prompt, context, None
return None, None, "cloud_synthetic_prompt_receipt_invalid"
chat_block = AIRouterExecutor._sre_chat_sanitization_receipt_block_reason(
prompt,
context,
)
if chat_block is None:
return prompt, context, None
return None, None, chat_block
cloud_context = (context or {}).get("paid_cloud_context")
paid_prompt = (context or {}).get("paid_cloud_prompt")
if not isinstance(cloud_context, dict) or not isinstance(paid_prompt, str):
return None, None, "alert_cloud_sanitization_receipt_missing"
receipt = cloud_context.get("cloud_sanitization_receipt")
payload = cloud_context.get("sanitized_payload")
if not isinstance(receipt, dict) or not isinstance(payload, dict):
return None, None, "alert_cloud_sanitization_receipt_missing"
if (
receipt.get("schema_version") != "openclaw_paid_cloud_sanitization_v1"
or receipt.get("status") != "verified"
or receipt.get("sanitizer") != "src.services.sanitization_service.sanitize"
or receipt.get("classifier") != "allowlisted_structured_alert_fields_v1"
or receipt.get("dlp_policy") != "paid_cloud_allowlist_dlp_v1"
or receipt.get("raw_payload_forwarded") is not False
or receipt.get("raw_log_payload_forwarded") is not False
or receipt.get("secret_value_exposed") is not False
or receipt.get("private_network_value_exposed") is not False
):
return None, None, "alert_cloud_sanitization_receipt_invalid"
canonical = _json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
payload_sha256 = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
prompt_sha256 = hashlib.sha256(paid_prompt.encode("utf-8")).hexdigest()
if receipt.get("payload_sha256") != payload_sha256:
return None, None, "alert_cloud_sanitization_payload_mismatch"
if cloud_context.get("paid_prompt_sha256") != prompt_sha256:
return None, None, "alert_cloud_sanitized_prompt_mismatch"
# Receipt fields are caller-controlled until they are rebound to the
# original alert through the canonical allowlist sanitizer. A matching
# self-signed hash alone is not an authority boundary.
from src.services.openclaw import build_paid_cloud_alert_context
rebuilt_context, rebuilt_reason = build_paid_cloud_alert_context(context)
if rebuilt_context is None:
return None, None, rebuilt_reason
rebuilt_receipt = rebuilt_context.get("cloud_sanitization_receipt") or {}
if (
rebuilt_context.get("sanitized_payload") != payload
or rebuilt_receipt.get("receipt_id") != receipt.get("receipt_id")
):
return None, None, "alert_cloud_sanitization_receipt_untrusted"
restricted_context = dict(cloud_context)
for correlation_field in ("trace_id", "run_id", "work_item_id"):
if (context or {}).get(correlation_field):
restricted_context[correlation_field] = (context or {})[
correlation_field
]
return paid_prompt, restricted_context, None
@staticmethod
async def _bounded_ollama_unavailable_receipts(
context: dict | None,
) -> set[str]:
"""Verify current-run free-lane unavailability against durable truth.
Caller-provided JSON is only a lookup hint. The full receipt must exist
independently in Redis, carry a short expiry, and acknowledge a
completed failover-manager verifier before it can replace an attempt.
"""
run_id = str((context or {}).get("run_id") or "")
if not run_id:
return set()
raw_receipts = (context or {}).get("ollama_unavailable_receipts") or []
if isinstance(raw_receipts, dict):
raw_receipts = list(raw_receipts.values())
valid: set[str] = set()
allowed_reasons = {
"endpoint_unreachable",
"health_probe_failed",
"provider_not_registered",
"provider_disabled",
"bounded_deadline_exhausted",
}
try:
from src.core.redis_client import get_redis
redis = get_redis()
except Exception as redis_error:
logger.warning(
"ai_router_ollama_unavailable_receipt_store_unavailable",
error_type=type(redis_error).__name__,
)
return set()
now = datetime.now(UTC)
bounded_receipts = (
raw_receipts[: len(PRODUCTION_OLLAMA_ORDER)]
if isinstance(raw_receipts, list)
else []
)
for receipt in bounded_receipts:
if not isinstance(receipt, dict):
continue
provider = str(receipt.get("provider") or "")
receipt_id = str(receipt.get("receipt_id") or "")
if not (
provider in PRODUCTION_OLLAMA_ORDER
and receipt.get("schema_version")
== _DURABLE_OLLAMA_UNAVAILABLE_RECEIPT_SCHEMA
and receipt.get("source") == "ollama_failover_manager"
and receipt.get("status") == "bounded_unavailable"
and str(receipt.get("run_id") or "") == run_id
and receipt.get("check_completed") is True
and str(receipt.get("reason") or "") in allowed_reasons
and _DURABLE_OLLAMA_UNAVAILABLE_RECEIPT_ID.fullmatch(receipt_id)
):
continue
try:
durable_raw = await redis.get(
f"{_DURABLE_OLLAMA_UNAVAILABLE_RECEIPT_PREFIX}{receipt_id}"
)
if isinstance(durable_raw, bytes):
durable_raw = durable_raw.decode("utf-8")
durable = _json.loads(durable_raw) if durable_raw else None
except Exception as receipt_error:
logger.warning(
"ai_router_ollama_unavailable_receipt_read_failed",
provider=provider,
error_type=type(receipt_error).__name__,
)
continue
if not isinstance(durable, dict):
continue
contract_fields = (
"schema_version",
"receipt_id",
"source",
"provider",
"run_id",
"status",
"check_completed",
"reason",
"observed_at",
"expires_at",
)
if any(durable.get(field) != receipt.get(field) for field in contract_fields):
continue
if (
durable.get("durable_write_ack") is not True
or durable.get("verifier_status") != "verified"
or durable.get("verified_by")
!= "ollama_failover_manager.health_probe"
):
continue
try:
observed_at = datetime.fromisoformat(
str(durable.get("observed_at") or "").replace("Z", "+00:00")
)
expires_at = datetime.fromisoformat(
str(durable.get("expires_at") or "").replace("Z", "+00:00")
)
except ValueError:
continue
if observed_at.tzinfo is None or expires_at.tzinfo is None:
continue
lifetime = (expires_at - observed_at).total_seconds()
if not (
0 < lifetime <= _DURABLE_OLLAMA_UNAVAILABLE_MAX_SECONDS
and observed_at <= now
and now < expires_at
):
continue
valid.add(provider)
return valid
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: 標準化結果
"""
asset_preflight = self._asset_execution_preflight(context)
if asset_preflight is not None:
logger.warning(
"ai_router_asset_identity_or_circuit_blocked",
reason=asset_preflight.error,
audit_schema=asset_preflight.audit_metadata.get("schema_version"),
)
return asset_preflight
# ① 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",
)
requested_provider_order = list(provider_order)
provider_order, rejected_providers = normalize_production_execution_order(
provider_order,
require_local=require_local,
)
if rejected_providers:
logger.warning(
"ai_router_nonproduction_provider_insertion_rejected",
rejected_providers=rejected_providers,
requested_provider_order=requested_provider_order,
normalized_provider_order=provider_order,
)
if not provider_order:
logger.error(
"ai_router_production_route_missing_ollama_lane",
requested_provider_order=requested_provider_order,
)
return AIResult(
raw_response="",
success=False,
provider="none",
error="production_route_missing_ollama_lane",
)
# Paid-provider runtime control is evaluated before cache lookup. This
# prevents a cached cloud response from bypassing a newly asserted
# durable disable and prevents Redis read failures from silently
# re-adding either paid lane.
preflight_errors: list[str] = []
from src.services.ai_control import is_provider_disabled
from src.services.ai_providers.interfaces import (
cloud_context_block_reason,
is_provider_enabled_by_env,
)
paid_prompt, paid_context, paid_input_block = self._paid_alert_execution_inputs(
prompt,
context,
)
cloud_privacy_block = paid_input_block or cloud_context_block_reason(
paid_context,
require_local=require_local,
)
if cloud_privacy_block:
blocked_cloud_providers = [
provider
for provider in SANITIZED_CLOUD_PROVIDER_ORDER
if provider in provider_order
]
provider_order = [
provider
for provider in provider_order
if provider not in SANITIZED_CLOUD_PROVIDER_ORDER
]
preflight_errors.extend(
f"{provider}: {cloud_privacy_block}"
for provider in blocked_cloud_providers
)
logger.warning(
"ai_router_cloud_context_blocked_pre_cache",
reason=cloud_privacy_block,
blocked_providers=blocked_cloud_providers,
)
if any(provider in provider_order for provider in PAID_PROVIDER_ORDER):
for paid_provider in PAID_PROVIDER_ORDER:
if paid_provider not in provider_order:
continue
if not is_provider_enabled_by_env(paid_provider):
provider_order = [
provider
for provider in provider_order
if provider != paid_provider
]
preflight_errors.append(
f"{paid_provider}: disabled_by_environment"
)
logger.info(
"ai_router_provider_environment_disabled_pre_cache",
provider=paid_provider,
)
continue
try:
if await is_provider_disabled(
paid_provider,
run_id=str((paid_context or context or {}).get("run_id") or ""),
):
provider_order = [
provider
for provider in provider_order
if provider != paid_provider
]
preflight_errors.append(
f"{paid_provider}: disabled_by_runtime_control"
)
logger.info(
"ai_router_provider_disabled_pre_cache",
provider=paid_provider,
)
except Exception as disable_check_error:
provider_order = [
provider
for provider in provider_order
if provider != paid_provider
]
preflight_errors.append(
f"{paid_provider}: disabled_state_unavailable"
)
logger.warning(
"ai_router_paid_disable_state_unavailable_pre_cache_blocked",
provider=paid_provider,
error_type=type(disable_check_error).__name__,
)
# A circuit-open first hop is a terminal route condition, not an
# unavailability receipt authorizing another host or provider. Check it
# before cache lookup so stale generations cannot bypass the circuit.
if provider_order:
first_provider = provider_order[0]
first_circuit = self._get_circuit_breaker(first_provider)
if first_circuit.is_open():
logger.warning(
"ai_router_provider_circuit_open_fail_closed",
provider=first_provider,
cross_provider_fallback_allowed=False,
)
return self._provider_circuit_open_result(
provider_name=first_provider,
circuit_breaker=first_circuit,
context=context,
)
# ② Cache 檢查 (D4)
cache_key = self._cache_key(prompt, context) # C3 修復: 移到 try 外避免 UnboundLocalError
try:
from src.core.redis_client import get_redis
redis = get_redis()
cached = await redis.get(cache_key)
if cached:
data = _json.loads(cached)
cached_provider = data.get("provider", "cache")
if _is_paid_provider_identity(cached_provider):
logger.info(
"ai_router_paid_cache_bypassed",
cache_key=cache_key[:30],
cached_provider=cached_provider,
reason="current_policy_and_ordered_attempt_recheck_required",
)
raise ValueError("paid provider cache is non-executable")
provider_allowed = cached_provider in provider_order
ollama_first_required = (
bool(context)
and any(
key in context
for key in (
"alert_type",
"alertname",
"alert_name",
"fingerprint",
"incident_id",
"severity",
"target_resource",
)
)
and bool(provider_order)
and provider_order[0].startswith("ollama")
) or (
bool(context)
and bool(context.get("enforce_ollama_first"))
and bool(provider_order)
and provider_order[0].startswith("ollama")
)
if (
cached_provider == "ollama"
and any(provider.startswith("ollama") for provider in provider_order)
):
provider_allowed = True
if ollama_first_required and not cached_provider.startswith("ollama"):
provider_allowed = False
if not provider_allowed:
logger.info(
"ai_router_cache_provider_mismatch_skip",
cache_key=cache_key[:30],
cached_provider=cached_provider,
provider_order=provider_order,
ollama_first_required=ollama_first_required,
)
raise ValueError("cached provider not allowed by current provider_order")
logger.info("ai_router_cache_hit", cache_key=cache_key[:30])
return AIResult(
raw_response=data.get("response", ""),
success=True,
provider=cached_provider,
from_cache=True,
)
except Exception as e:
logger.debug(
"ai_router_cache_read_failed",
error_type=type(e).__name__,
)
# ③ 遍歷 Provider + 閘門 (D3)
# 2026-04-02 ogt: C1 修復 — 建立 Langfuse Trace (D5)
# 包住整個執行鏈,記錄每個 Provider 的 generation
try:
from src.services.langfuse_client import langfuse_trace
alert_type_value = str((context or {}).get("alert_type", ""))
_lf_trace_ctx = langfuse_trace(
"ai_router_execute",
metadata={
"provider_order": provider_order,
"prompt_length": len(prompt),
"require_local": require_local,
"alert_type_length": len(alert_type_value),
"alert_type_sha256": hashlib.sha256(
alert_type_value.encode("utf-8")
).hexdigest(),
},
)
_lf_trace_ctx.__enter__()
except Exception:
_lf_trace_ctx = None
errors: list[str] = list(preflight_errors)
attempted_providers: set[str] = set()
bounded_unavailable_providers = (
await self._bounded_ollama_unavailable_receipts(context)
)
provider_timeout_seconds: float | None = None
try:
configured_provider_timeout = float(
(context or {}).get("provider_timeout_seconds") or 0
)
if configured_provider_timeout > 0:
provider_timeout_seconds = configured_provider_timeout
except (TypeError, ValueError):
logger.warning(
"ai_router_invalid_provider_timeout",
value=(context or {}).get("provider_timeout_seconds"),
)
alert_requires_ollama_before_cloud = bool(
(context or {}).get("alert_requires_ollama_before_cloud")
)
# 2026-04-27 Claude Sonnet 4.6: A2 INC-20260425 — DIAGNOSE fallback metric 追蹤
# 透過 context.get("intent_hint") 判斷是否為 DIAGNOSE,避免改動 execute() 簽名
# _last_attempted_provider 記錄上一輪嘗試的 provider,用於計算 from→to 關係
_is_diagnose_intent = str((context or {}).get("intent_hint", "")).strip().lower() == "diagnose"
_last_attempted_provider: str | None = None
for provider_name in provider_order:
# Opening a provider circuit terminates this canonical lane. It is
# checked before fallback metrics and registry/provider work so the
# skipped hop cannot be reported as an attempted fallback.
cb = self._get_circuit_breaker(provider_name)
if cb.is_open():
logger.warning(
"ai_router_provider_circuit_open_fail_closed",
provider=provider_name,
cross_provider_fallback_allowed=False,
)
if _lf_trace_ctx:
try:
_lf_trace_ctx.__exit__(None, None, None)
except Exception:
pass
return self._provider_circuit_open_result(
provider_name=provider_name,
circuit_breaker=cb,
context=context,
)
# 2026-04-27 Claude Sonnet 4.6: A2 — 若上一輪失敗且本輪開始,表示發生 fallback
# 記錄 metric(DIAGNOSE intent 專屬;非 DIAGNOSE 不記,不影響其他路徑)
# 2026-04-27 Claude Sonnet 4.6: F6 — fallback metric 只在真實 analyze() 失敗時觸發
# _last_attempted_provider 僅在 provider.analyze() 執行後失敗才賦值(見下方兩處);
# not_registered / privacy_skip / circuit_open / rate_limit 分支均不賦值,
# 避免這些「被跳過的 provider」誤計入 from→to fallback 鏈,metric 不可信問題(F6)。
if _is_diagnose_intent and _last_attempted_provider is not None:
try:
from src.core.metrics import record_diagnose_fallback
record_diagnose_fallback(
from_provider=_last_attempted_provider,
to_provider=provider_name,
)
logger.info(
"diagnose_fallback_recorded",
from_provider=_last_attempted_provider,
to_provider=provider_name,
)
except Exception as _metric_e:
# 2026-04-27 Claude Sonnet 4.6: F6 — 升 warning(原 debug 會 silent swallow)
# + 計入 error counter 讓 metric 管道問題可被 Prometheus 偵測
logger.warning("diagnose_fallback_metric_failed", error=str(_metric_e))
try:
from src.core.metrics import (
AIOPS_DIAGNOSE_FALLBACK_METRIC_ERROR_TOTAL,
)
AIOPS_DIAGNOSE_FALLBACK_METRIC_ERROR_TOTAL.inc()
except Exception:
pass
provider = self._registry.get(provider_name)
if provider is None and provider_name == "ollama_gcp_a":
# Compatibility for isolated unit registries. Production's
# registry exposes both the concrete GCP-A identity and the
# legacy logical ``ollama`` alias, while execution receipts
# continue to carry the provider's concrete runtime identity.
provider = self._registry.get("ollama")
if provider is None and provider_name == "ollama_local" and require_local:
# Older isolated/local registries expose the logical Ollama
# provider only. It may satisfy the concrete host111 lane only
# when the provider itself declares a local privacy boundary;
# production keeps using its explicit ollama_local identity.
legacy_local = self._registry.get("ollama")
if (
legacy_local is not None
and getattr(legacy_local, "privacy_level", None) == "local"
):
provider = legacy_local
if not provider:
# 2026-04-14 Claude Sonnet 4.6: silent skip 改 errors 累積(觀測性)
# 2026-04-27 Claude Sonnet 4.6: F6 — 不設 _last_attempted_provider(未真實執行 analyze)
errors.append(f"{provider_name}: not_registered")
continue
if provider_name in PAID_PROVIDER_ORDER:
unresolved_ollama_hops = [
hop
for hop in PRODUCTION_OLLAMA_ORDER
if hop not in attempted_providers
and hop not in bounded_unavailable_providers
]
if unresolved_ollama_hops:
errors.append(
f"{provider_name}: cloud_blocked_ollama_attempt_receipts_missing("
f"{','.join(unresolved_ollama_hops)})"
)
logger.warning(
"ai_router_cloud_blocked_missing_ollama_attempt_receipts",
provider=provider_name,
unresolved_ollama_hops=unresolved_ollama_hops,
attempted_providers=sorted(attempted_providers),
bounded_unavailable_providers=sorted(
bounded_unavailable_providers
),
)
continue
# 隱私過濾 (D7)
# 2026-04-27 Claude Sonnet 4.6: F6 — privacy_skip 不設 _last_attempted_provider(未嘗試)
if require_local and provider.privacy_level != "local":
errors.append(f"{provider_name}: privacy_skip(non_local)")
continue
# GCP-A/B are cloud-hosted Ollama and therefore non-local for data
# classification, but they remain the first two free Ollama hops.
# The "Ollama before cloud critic" gate applies only to paid
# external critics, otherwise it would incorrectly block GCP-A/B.
if (
alert_requires_ollama_before_cloud
and provider_name in PAID_PROVIDER_ORDER
):
if "ollama_local" not in attempted_providers:
errors.append(f"{provider_name}: blocked_until_ollama_local_attempted")
logger.warning(
"ai_router_cloud_blocked_until_ollama_local_attempted",
provider=provider_name,
provider_order=provider_order,
attempted_providers=sorted(attempted_providers),
)
continue
# 閘門 2: Rate Limiter
# 2026-04-02 Claude Code: Phase 24 B3 + C1 修復 — Rate Limiter (含 openclaw_nemo)
# Paid providers own atomic requests/tokens/cost reservations inside
# their provider implementations; legacy free/provider lanes retain
# this compatibility rate gate.
if provider_name in ("openclaw_nemo", "nemotron"):
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:
errors.append(f"{provider_name}: rate_limit({reason})")
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:
provider_prompt = (
paid_prompt
if provider_name in SANITIZED_CLOUD_PROVIDER_ORDER
else prompt
)
provider_context = (
paid_context
if provider_name in SANITIZED_CLOUD_PROVIDER_ORDER
else context
)
if provider_name in SANITIZED_CLOUD_PROVIDER_ORDER and (
provider_prompt is None or provider_context is None
):
errors.append(
f"{provider_name}: sanitized_cloud_execution_inputs_missing"
)
continue
if (
provider_name in {"ollama_gcp_a", "ollama_gcp_b"}
and (context or {}).get("cloud_transport_auto_preflight")
is True
):
provider_context, transport_block = (
await self._attach_gcp_transport_receipt(
provider_name=provider_name,
provider_context=provider_context,
)
)
if transport_block:
errors.append(
f"{provider_name}: {transport_block}"
)
continue
paid_context = provider_context
attempted_providers.add(provider_name)
provider_call = provider.analyze(
provider_prompt,
provider_context,
)
result = (
await asyncio.wait_for(
provider_call,
timeout=provider_timeout_seconds,
)
if provider_timeout_seconds is not None
else await provider_call
)
if result.success:
# 記錄成功 (per-provider CB)
cb.record_success()
# 記錄費用
if (
result.cost_usd > 0
and provider_name not in PAID_PROVIDER_ORDER
):
try:
rate_limiter = get_ai_rate_limiter()
await rate_limiter.record_cost(provider_name, result.cost_usd)
except Exception:
pass
# Paid responses are deliberately non-cacheable: every
# paid candidate must re-run current durable policy,
# privacy, canary, cost, and ordered-attempt gates.
if provider_name not in SANITIZED_CLOUD_PROVIDER_ORDER:
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,
)
# D5: 記錄 Langfuse generation
if _lf_trace_ctx:
try:
cloud_provider = (
provider_name in SANITIZED_CLOUD_PROVIDER_ORDER
)
prompt_hash = hashlib.sha256(
provider_prompt.encode("utf-8")
).hexdigest()
response_hash = hashlib.sha256(
result.raw_response.encode("utf-8")
).hexdigest()
receipt = (
(provider_context or {}).get(
"cloud_sanitization_receipt"
)
or {}
)
_lf_trace_ctx.generation(
name=f"{provider_name}_call",
model=provider_name,
input=None if cloud_provider else provider_prompt[:500],
output=(
None
if cloud_provider
else result.raw_response[:500]
),
usage={"total": result.tokens} if result.tokens else None,
metadata={
"cost_usd": result.cost_usd,
"latency_ms": round(result.latency_ms, 1),
"content_redacted": cloud_provider,
"prompt_length": len(provider_prompt),
"prompt_sha256": prompt_hash,
"response_length": len(result.raw_response),
"response_sha256": response_hash,
"sanitization_receipt_id": (
receipt.get("receipt_id")
if isinstance(receipt, dict)
else None
),
"generation_receipt_id": result.audit_metadata.get(
"generation_receipt_id"
),
},
)
_lf_trace_ctx.__exit__(None, None, None)
except Exception:
pass
return result
# Provider 回傳 success=False
cb.record_failure()
if provider_name in PAID_PROVIDER_ORDER:
error_text = str(result.error or "")
error_sha256 = hashlib.sha256(
error_text.encode("utf-8")
).hexdigest()
errors.append(
f"{provider_name}: provider_failed(error_sha256={error_sha256})"
)
logger.warning(
"ai_router_paid_provider_failed",
provider=provider_name,
error_length=len(error_text),
error_sha256=error_sha256,
)
else:
errors.append(f"{provider_name}: {result.error}")
logger.warning(
"ai_router_provider_failed",
provider=provider_name,
error=result.error,
)
# 2026-04-27 A2: 記錄失敗的 provider,供下輪迭代計算 fallback metric
_last_attempted_provider = provider_name
except Exception as e:
if provider_name in PAID_PROVIDER_ORDER:
error_text = str(e)
error_sha256 = hashlib.sha256(
error_text.encode("utf-8")
).hexdigest()
errors.append(
f"{provider_name}: provider_exception(error_sha256={error_sha256})"
)
logger.warning(
"ai_router_paid_provider_exception",
provider=provider_name,
error_type=type(e).__name__,
error_length=len(error_text),
error_sha256=error_sha256,
)
else:
errors.append(f"{provider_name}: {e}")
logger.warning(
"ai_router_provider_exception",
provider=provider_name,
error=str(e),
)
# 2026-04-05 Claude Code: v4.3 — Timeout 不計 CB 失敗
# NIM 偶爾 GPU 忙碌導致 27s,timeout 不代表 NIM 故障
# 只有明確連線錯誤(非 timeout)才累積 CB 失敗次數
if not isinstance(e, _httpx.TimeoutException | TimeoutError):
cb.record_failure()
# 2026-04-27 A2: 記錄失敗的 provider,供下輪迭代計算 fallback metric
_last_attempted_provider = provider_name
# 全部失敗
logger.error("ai_router_all_providers_failed", tried=provider_order, errors=errors)
if _lf_trace_ctx:
try:
_lf_trace_ctx.__exit__(None, None, None)
except Exception:
pass
# 2026-04-04 ogt: Phase 25 P0 — require_local 全部失敗時 Telegram 通知(隱私邊界)
# 2026-04-15 ogt: 改用 ADR-075 TYPE-1 格式,禁止純文字 raw notification
if require_local:
try:
from src.services.telegram_gateway import get_telegram_gateway
tg = get_telegram_gateway()
import asyncio as _asyncio
tried_str = ", ".join(provider_order)
formatted = (
"⚠️ TYPE-1 | AI Provider 不可用\n"
"──────────────────────\n"
f"├─ 已嘗試: {tried_str}\n"
"└─ 原因: require_local=True,無可用本地 Provider\n"
"\n"
"需要人工介入"
)
_asyncio.create_task(
tg.send_alert_notification(
formatted,
parse_mode="HTML",
product_id="awoooi",
signal_family="shared_infrastructure",
severity="P1",
)
)
except Exception as _tg_e:
logger.warning("diagnose_reject_telegram_failed", error=str(_tg_e))
return AIResult(
raw_response="",
success=False,
provider="none",
error="local_providers_unavailable",
)
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.claude import ClaudeProvider
from src.services.ai_providers.gemini import GeminiProvider
from src.services.ai_providers.ollama import (
OllamaGcpBProvider, # 2026-05-04 ADR-110 GCP-B
OllamaLocalProvider,
OllamaProvider,
)
from src.services.ai_providers.openclaw_nemo import OpenClawNemoProvider
registry = AIProviderRegistry()
# GCP-A Primary(name="ollama",OLLAMA_URL)
ollama_gcp_a = OllamaProvider()
registry.register(ollama_gcp_a)
registry.register(GeminiProvider())
registry.register(ClaudeProvider())
registry.register(OpenClawNemoProvider())
# 2026-04-02 Claude Code: Phase 24 B3 — 加入 NemotronProvider (tool_calling 優先)
from src.services.ai_providers.nemotron import NemotronProvider
registry.register(NemotronProvider())
# 2026-05-06 Codex: 188 不再作為 Ollama provider。
# Local fallback 統一命名為 ollama_local,端點由 OLLAMA_FALLBACK_URL 直連 host111。
ollama_local = OllamaLocalProvider()
registry.register(ollama_local)
# 2026-05-04 ogt + Claude Sonnet 4.6: ADR-110 GCP 三層容災修復
# 根因:OllamaFailoverManager 回傳 "ollama_gcp_a"/"ollama_gcp_b"/"ollama_local"
# 但 registry 無這些名稱 → not_registered → 整條 Ollama 鏈跳過 → 直接跳 Gemini
# 修復:
# "ollama_gcp_a" alias → 同 OllamaProvider(OLLAMA_URL = GCP-A)
# "ollama_gcp_b" → 新 OllamaGcpBProvider(OLLAMA_SECONDARY_URL = GCP-B)
# "ollama_local" → OllamaLocalProvider(OLLAMA_FALLBACK_URL = host111 direct)
registry._providers["ollama_gcp_a"] = ollama_gcp_a
registry.register(OllamaGcpBProvider())
registry._providers["ollama_local"] = ollama_local
return registry
def get_ai_router() -> 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, _registry, _executor
_router = None
_registry = None
_executor = None