""" AI Control Service — Phase 24 C (2026-04-03 ogt) Telegram /ai 指令動態控制 AI Router 狀態 - Redis 狀態優先於 env var - OPENCLAW_TG_USER_WHITELIST 白名單保護 Redis Keys: ai:control:use_router "true"/"false" (TTL: 30天) ai:control:primary_provider legacy compatibility; only "ollama"/"ollama_gcp_a" ai:control:disabled: "1" disabled / Gemini "0" enabled (Gemini 無 TTL;其他 provider 30 天) Usage: /ai status — 顯示所有 Provider 狀態 + 當前路由模式 /ai primary

— legacy compatibility; production always starts at GCP-A /ai enable

— 啟用特定 Provider /ai disable

— 停用特定 Provider /ai cost — 顯示費用統計 /ai router on — 啟用 AIRouter (覆蓋 env var) /ai router off — 停用 AIRouter (回滾到舊 fallback chain) """ import structlog logger = structlog.get_logger(__name__) # Redis Key 常數 _AI_ROUTER_KEY = "ai:control:use_router" _PRIMARY_PROVIDER_KEY = "ai:control:primary_provider" _DISABLED_KEY_PREFIX = "ai:control:disabled:" # Free-provider/router compatibility controls retain the historical 30-day # expiry. Gemini's explicit paid-provider state is intentionally persistent. _CONTROL_KEY_TTL = 30 * 24 * 3600 # 30 days VALID_PROVIDERS = { "ollama", "ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini", } VALID_PRIMARY_PROVIDERS = {"ollama", "ollama_gcp_a"} SHADOW_METADATA_ONLY_PROVIDERS = { "claude", "nvidia", "nemotron", "openclaw", "openclaw_nemo", "ollama_tool", } async def _get_redis(): from src.core.redis_client import get_redis return get_redis() async def get_ai_router_enabled() -> bool | None: """ 從 Redis 取得 AIRouter 啟用狀態。 回傳 None 表示未設定,使用 env var (USE_AI_ROUTER)。 """ try: r = await _get_redis() val = await r.get(_AI_ROUTER_KEY) if val is None: return None return val.decode() == "true" if isinstance(val, bytes) else val == "true" except Exception as e: logger.warning("ai_control_redis_read_failed", key=_AI_ROUTER_KEY, error=str(e)) return None async def set_ai_router_enabled(enabled: bool) -> bool: """設定 AIRouter 啟用狀態到 Redis""" try: r = await _get_redis() await r.set(_AI_ROUTER_KEY, "true" if enabled else "false", ex=_CONTROL_KEY_TTL) logger.info("ai_control_router_set", enabled=enabled) return True except Exception as e: logger.error("ai_control_router_set_failed", error=str(e)) return False async def get_primary_provider() -> str | None: """從 Redis 取得主要 Provider,None 表示使用 Router 智慧路由""" try: r = await _get_redis() val = await r.get(_PRIMARY_PROVIDER_KEY) if val is None: return None return val.decode() if isinstance(val, bytes) else val except Exception: return None async def set_primary_provider(provider: str) -> bool: """Retain only the fixed GCP-A-compatible legacy override.""" if provider not in VALID_PRIMARY_PROVIDERS: return False try: r = await _get_redis() await r.set(_PRIMARY_PROVIDER_KEY, provider, ex=_CONTROL_KEY_TTL) logger.info("ai_control_primary_set", provider=provider) return True except Exception as e: logger.error("ai_control_primary_set_failed", error=str(e)) return False async def clear_primary_provider() -> bool: """清除主要 Provider 設定(回歸智慧路由)""" try: r = await _get_redis() await r.delete(_PRIMARY_PROVIDER_KEY) return True except Exception: return False async def is_provider_disabled(provider: str) -> bool: """檢查 Provider 是否被停用""" try: r = await _get_redis() val = await r.get(f"{_DISABLED_KEY_PREFIX}{provider}") # The paid final fallback has no implicit enable state. A missing # durable control key is disabled until an operator explicitly enables # it; free Ollama lanes retain their historical missing-key=enabled # behaviour. if provider == "gemini": return val is None or ( val.decode() if isinstance(val, bytes) else str(val) ) != "0" return val is not None except Exception: # Paid-provider disable truth is fail-closed. Callers can catch this to # publish an unavailable readback, but must never re-add Gemini. if provider == "gemini": raise return False async def set_provider_disabled(provider: str, disabled: bool) -> bool: """設定 Provider 啟用/停用""" if provider not in VALID_PROVIDERS: return False try: r = await _get_redis() key = f"{_DISABLED_KEY_PREFIX}{provider}" if disabled: if provider == "gemini": # Paid-provider disable truth must not silently expire. await r.set(key, "1") else: await r.set(key, "1", ex=_CONTROL_KEY_TTL) else: if provider == "gemini": # Explicitly persist the enabled state. Deleting the key would # fail closed to disabled on the next read. await r.set(key, "0") else: await r.delete(key) logger.info("ai_control_provider_set", provider=provider, disabled=disabled) return True except Exception as e: logger.error("ai_control_provider_set_failed", error=str(e)) return False async def get_status_summary() -> str: """ 取得 AI 控制狀態摘要 (Telegram 格式 HTML) """ from src.core.config import get_settings settings = get_settings() # AIRouter 狀態 redis_router = await get_ai_router_enabled() if redis_router is not None: router_status = "✅ ON (Redis 覆蓋)" if redis_router else "❌ OFF (Redis 覆蓋)" else: router_status = "✅ ON (env)" if settings.USE_AI_ROUTER else "❌ OFF (env)" # Primary Provider primary = await get_primary_provider() primary_str = f"🎯 {primary}" if primary else "🤖 智慧路由 (AIRouter 決定)" # Provider 狀態 provider_lines = [] for p in ["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini"]: try: disabled = await is_provider_disabled(p) except Exception: disabled = True icon = "❌" if disabled else "✅" provider_lines.append(f" {icon} {p}") provider_lines.extend( f" ⚪ {provider} (shadow metadata only)" for provider in sorted(SHADOW_METADATA_ONLY_PROVIDERS) ) # 費用統計 cost_lines = [] try: r = await _get_redis() for p in ["gemini"]: val = await r.get(f"ai_rate:total_cost:{p}") if val: cost = float(val) if cost > 0: cost_lines.append(f" 💰 {p}: ${cost:.4f}") except Exception: cost_lines.append(" (費用統計不可用)") providers_str = "\n".join(provider_lines) costs_str = "\n".join(cost_lines) if cost_lines else " (尚無費用記錄)" return ( f"🤖 AI Router 控制面板\n\n" f"路由模式: {router_status}\n" f"主要 Provider: {primary_str}\n\n" f"Provider 狀態:\n{providers_str}\n\n" f"費用統計:\n{costs_str}\n\n" f"/ai primary <provider> 切換\n" f"/ai router on/off 開關 AIRouter\n" f"/ai enable/disable <provider> 控制 Provider" ) async def handle_ai_command(text: str) -> str: """ 處理 /ai 指令,回傳 Telegram HTML 回應。 Args: text: 完整訊息文字 (e.g., "/ai status", "/ai primary gemini") Returns: HTML 格式回應字串 """ parts = text.strip().split() if len(parts) < 2: return await get_status_summary() sub = parts[1].lower() if sub == "status": return await get_status_summary() elif sub == "router": if len(parts) < 3: return "用法: /ai router on/ai router off" action = parts[2].lower() if action in ("on", "true", "enable"): ok = await set_ai_router_enabled(True) return "✅ AIRouter 已啟用 (Redis 覆蓋)" if ok else "❌ 寫入 Redis 失敗" elif action in ("off", "false", "disable"): ok = await set_ai_router_enabled(False) return "⚠️ AIRouter 已停用,回滾舊 fallback chain (Redis 覆蓋)" if ok else "❌ 寫入 Redis 失敗" else: return f"❌ 未知動作: {action},請用 on/off" elif sub == "primary": if len(parts) < 3: current = await get_primary_provider() if current: return f"當前主要 Provider: {current}\n用法: /ai primary <provider>/ai primary auto" return "當前: 🤖 智慧路由\n用法: /ai primary <provider>" provider = parts[2].lower() if provider == "auto": ok = await clear_primary_provider() return "✅ 已清除主要 Provider,恢復智慧路由" if ok else "❌ 操作失敗" if provider not in VALID_PRIMARY_PROVIDERS: return ( f"❌ Provider 不可改寫 production 起點: {provider}\n" "production 固定從 ollama_gcp_a 開始" ) ok = await set_primary_provider(provider) return f"✅ 主要 Provider 已設為 {provider}" if ok else "❌ 寫入 Redis 失敗" elif sub == "enable": if len(parts) < 3: return "用法: /ai enable <provider>" provider = parts[2].lower() if provider not in VALID_PROVIDERS: return f"❌ 未知 Provider: {provider}" ok = await set_provider_disabled(provider, False) return f"✅ {provider} 已啟用" if ok else "❌ 操作失敗" elif sub == "disable": if len(parts) < 3: return "用法: /ai disable <provider>" provider = parts[2].lower() if provider not in VALID_PROVIDERS: return f"❌ 未知 Provider: {provider}" ok = await set_provider_disabled(provider, True) return f"⚠️ {provider} 已停用" if ok else "❌ 操作失敗" elif sub == "cost": lines = ["💰 AI 費用統計\n"] try: r = await _get_redis() total = 0.0 for p in ["gemini"]: val = await r.get(f"ai_rate:total_cost:{p}") if val: cost = float(val) total += cost if cost > 0: lines.append(f" {p}: ${cost:.4f}") if total > 0: lines.append(f"\n 總計: ${total:.4f}") else: lines.append(" (尚無費用記錄)") except Exception as e: lines.append(f" ⚠️ 費用查詢失敗: {e}") return "\n".join(lines) elif sub == "help": return ( "/ai 指令說明\n\n" "/ai status — 顯示所有狀態\n" "/ai router on/off — 開關 AIRouter\n" "/ai primary <p> — 設主要 Provider\n" "/ai primary auto — 恢復智慧路由\n" "/ai enable <p> — 啟用 Provider\n" "/ai disable <p> — 停用 Provider\n" "/ai cost — 費用統計\n\n" f"可控 Provider: {', '.join(sorted(VALID_PROVIDERS))}\n" "Legacy providers 僅保留 shadow metadata,不可執行" ) else: return f"❌ 未知子指令: {sub}\n輸入 /ai help 查看說明"