feat(ai): add guarded Claude fallback route

This commit is contained in:
ogt
2026-07-15 15:57:24 +08:00
parent f57845ba5e
commit 5c5eb48091
56 changed files with 4942 additions and 760 deletions

View File

@@ -8,8 +8,8 @@ Telegram /ai 指令動態控制 AI Router 狀態
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 天)
ai:control:disabled:<provider> "1" disabled / paid provider "0" enabled
(paid providers 無 TTLfree providers 30 天)
Usage:
/ai status — 顯示所有 Provider 狀態 + 當前路由模式
@@ -31,19 +31,20 @@ _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.
# expiry. Paid-provider explicit control state is intentionally persistent.
_CONTROL_KEY_TTL = 30 * 24 * 3600 # 30 days
PAID_PROVIDERS = {"claude", "gemini"}
VALID_PROVIDERS = {
"ollama",
"ollama_gcp_a",
"ollama_gcp_b",
"ollama_local",
"claude",
"gemini",
}
VALID_PRIMARY_PROVIDERS = {"ollama", "ollama_gcp_a"}
SHADOW_METADATA_ONLY_PROVIDERS = {
"claude",
"nvidia",
"nemotron",
"openclaw",
@@ -130,15 +131,15 @@ async def is_provider_disabled(provider: str) -> bool:
# durable control key is disabled until an operator explicitly enables
# it; free Ollama lanes retain their historical missing-key=enabled
# behaviour.
if provider == "gemini":
if provider in PAID_PROVIDERS:
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":
# publish an unavailable readback, but must never re-add a paid lane.
if provider in PAID_PROVIDERS:
raise
return False
@@ -151,13 +152,13 @@ async def set_provider_disabled(provider: str, disabled: bool) -> bool:
r = await _get_redis()
key = f"{_DISABLED_KEY_PREFIX}{provider}"
if disabled:
if provider == "gemini":
if provider in PAID_PROVIDERS:
# 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":
if provider in PAID_PROVIDERS:
# Explicitly persist the enabled state. Deleting the key would
# fail closed to disabled on the next read.
await r.set(key, "0")
@@ -190,7 +191,13 @@ async def get_status_summary() -> str:
# Provider 狀態
provider_lines = []
for p in ["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini"]:
for p in [
"ollama_gcp_a",
"ollama_gcp_b",
"ollama_local",
"claude",
"gemini",
]:
try:
disabled = await is_provider_disabled(p)
except Exception:
@@ -206,7 +213,7 @@ async def get_status_summary() -> str:
cost_lines = []
try:
r = await _get_redis()
for p in ["gemini"]:
for p in ["claude", "gemini"]:
val = await r.get(f"ai_rate:total_cost:{p}")
if val:
cost = float(val)
@@ -303,7 +310,7 @@ async def handle_ai_command(text: str) -> str:
try:
r = await _get_redis()
total = 0.0
for p in ["gemini"]:
for p in ["claude", "gemini"]:
val = await r.get(f"ai_rate:total_cost:{p}")
if val:
cost = float(val)
@@ -329,7 +336,7 @@ async def handle_ai_command(text: str) -> str:
"<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,不可執行"
"Legacy providers 僅保留 shadow metadataClaude/Gemini 受 persistent paid gate 控制"
)
else: