Files
awoooi/apps/api/src/services/ai_control.py

337 lines
12 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 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 取得主要 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:
"""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 &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_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 &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 ["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 &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))}\n"
"Legacy providers 僅保留 shadow metadata不可執行"
)
else:
return f"❌ 未知子指令: {sub}\n輸入 <code>/ai help</code> 查看說明"