Files
awoooi/apps/api/src/services/ai_control.py
OG T e60225ea29
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 36s
fix(ai): I1+I3 — Redis TTL + openclaw_nemo 命名對齊
I1: ai_control.py 所有寫入 Redis 的 key 加入 30 天 TTL
    防止 ai:control:* keys 永久累積造成記憶體洩漏

I3: ai_rate_limiter.py "nvidia" key → "openclaw_nemo"
    對齊 Phase 24 AIProviderEnum,使 rate limit 正確作用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:22:36 +08:00

288 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 "openclaw_nemo"/"gemini"/"ollama"/"claude" (TTL: 30天)
ai:control:disabled:<provider> "1" (TTL: 30天)
Usage:
/ai status — 顯示所有 Provider 狀態 + 當前路由模式
/ai primary <p> — 設定主要 Provider (openclaw_nemo/gemini/ollama/claude)
/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:"
# 2026-04-03 ogt: I1 防止記憶體洩漏 — 所有控制 key 統一 30 天 TTL
_CONTROL_KEY_TTL = 30 * 24 * 3600 # 30 days
VALID_PROVIDERS = {"openclaw_nemo", "gemini", "ollama", "claude", "nemotron"}
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 取得主要 ProviderNone 表示使用 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:
"""設定主要 Provider 到 Redis"""
if provider not in VALID_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}")
return val is not None
except Exception:
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:
await r.set(key, "1", ex=_CONTROL_KEY_TTL)
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 ["openclaw_nemo", "gemini", "ollama", "claude", "nemotron"]:
disabled = await is_provider_disabled(p)
icon = "" if disabled else ""
provider_lines.append(f" {icon} {p}")
# 費用統計
cost_lines = []
try:
r = await _get_redis()
for p in ["openclaw_nemo", "gemini", "claude", "nemotron"]:
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 &lt;provider&gt;</code> 切換\n"
f"<code>/ai router on/off</code> 開關 AIRouter\n"
f"<code>/ai enable/disable &lt;provider&gt;</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 &lt;provider&gt;</code> 或 <code>/ai primary auto</code>"
return "當前: 🤖 智慧路由\n用法: <code>/ai primary &lt;provider&gt;</code>"
provider = parts[2].lower()
if provider == "auto":
ok = await clear_primary_provider()
return "✅ 已清除主要 Provider恢復智慧路由" if ok else "❌ 操作失敗"
if provider not in VALID_PROVIDERS:
return f"❌ 未知 Provider: {provider}\n可用: {', '.join(sorted(VALID_PROVIDERS))}"
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 &lt;provider&gt;</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 &lt;provider&gt;</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 ["openclaw_nemo", "gemini", "claude", "nemotron"]:
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 &lt;p&gt;</code> — 設主要 Provider\n"
"<code>/ai primary auto</code> — 恢復智慧路由\n"
"<code>/ai enable &lt;p&gt;</code> — 啟用 Provider\n"
"<code>/ai disable &lt;p&gt;</code> — 停用 Provider\n"
"<code>/ai cost</code> — 費用統計\n\n"
f"可用 Provider: {', '.join(sorted(VALID_PROVIDERS))}"
)
else:
return f"❌ 未知子指令: {sub}\n輸入 <code>/ai help</code> 查看說明"