337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""
|
||
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:<provider> "1" disabled / Gemini "0" enabled
|
||
(Gemini 無 TTL;其他 provider 30 天)
|
||
|
||
Usage:
|
||
/ai status — 顯示所有 Provider 狀態 + 當前路由模式
|
||
/ai primary <p> — legacy compatibility; production always starts at GCP-A
|
||
/ai enable <p> — 啟用特定 Provider
|
||
/ai disable <p> — 停用特定 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"<b>🤖 AI Router 控制面板</b>\n\n"
|
||
f"<b>路由模式</b>: {router_status}\n"
|
||
f"<b>主要 Provider</b>: {primary_str}\n\n"
|
||
f"<b>Provider 狀態</b>:\n{providers_str}\n\n"
|
||
f"<b>費用統計</b>:\n{costs_str}\n\n"
|
||
f"<code>/ai primary <provider></code> 切換\n"
|
||
f"<code>/ai router on/off</code> 開關 AIRouter\n"
|
||
f"<code>/ai enable/disable <provider></code> 控制 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 "用法: <code>/ai router on</code> 或 <code>/ai router off</code>"
|
||
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: <b>{current}</b>\n用法: <code>/ai primary <provider></code> 或 <code>/ai primary auto</code>"
|
||
return "當前: 🤖 智慧路由\n用法: <code>/ai primary <provider></code>"
|
||
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 已設為 <b>{provider}</b>" if ok else "❌ 寫入 Redis 失敗"
|
||
|
||
elif sub == "enable":
|
||
if len(parts) < 3:
|
||
return "用法: <code>/ai enable <provider></code>"
|
||
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 "用法: <code>/ai disable <provider></code>"
|
||
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 = ["<b>💰 AI 費用統計</b>\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 <b>總計: ${total:.4f}</b>")
|
||
else:
|
||
lines.append(" (尚無費用記錄)")
|
||
except Exception as e:
|
||
lines.append(f" ⚠️ 費用查詢失敗: {e}")
|
||
return "\n".join(lines)
|
||
|
||
elif sub == "help":
|
||
return (
|
||
"<b>/ai 指令說明</b>\n\n"
|
||
"<code>/ai status</code> — 顯示所有狀態\n"
|
||
"<code>/ai router on/off</code> — 開關 AIRouter\n"
|
||
"<code>/ai primary <p></code> — 設主要 Provider\n"
|
||
"<code>/ai primary auto</code> — 恢復智慧路由\n"
|
||
"<code>/ai enable <p></code> — 啟用 Provider\n"
|
||
"<code>/ai disable <p></code> — 停用 Provider\n"
|
||
"<code>/ai cost</code> — 費用統計\n\n"
|
||
f"可控 Provider: {', '.join(sorted(VALID_PROVIDERS))}\n"
|
||
"Legacy providers 僅保留 shadow metadata,不可執行"
|
||
)
|
||
|
||
else:
|
||
return f"❌ 未知子指令: {sub}\n輸入 <code>/ai help</code> 查看說明"
|