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

@@ -4,7 +4,7 @@ AWOOOI API Configuration
Pydantic Settings + Environment Variables
ADR-005: BFF Architecture
ADR-006: AI Fallback Strategy (GCP-A -> GCP-B -> host111 -> Gemini)
ADR-006: AI Fallback Strategy (GCP-A -> GCP-B -> host111 -> Claude -> Gemini)
Four Iron Laws:
1. Async-First
@@ -428,8 +428,8 @@ class Settings(BaseSettings):
default=["bge-m3:latest", "qwen2.5:7b-instruct", "qwen3:14b", "deepseek-r1:14b", "hermes3:latest"],
description="HeartbeatReportService 探測必要模型是否載入",
)
# Gemini is the final paid fallback after GCP-A -> GCP-B -> host111.
# All generation calls atomically reserve requests/tokens/cost in Redis.
# Claude and Gemini are paid fallbacks after GCP-A -> GCP-B -> host111.
# All paid generation calls atomically reserve requests/tokens/cost in Redis.
GEMINI_MODEL: str = Field(
default="gemini-2.5-flash-lite",
min_length=1,
@@ -486,6 +486,78 @@ class Settings(BaseSettings):
le=3600,
description="Gemini failed generation work-item cooldown seconds",
)
CLAUDE_MODEL: str = Field(
default="claude-sonnet-5",
min_length=1,
description=(
"Anthropic SRE/architecture paid fallback model; exact model must "
"have a reviewed pricing policy before any generation"
),
)
CLAUDE_DAILY_QUOTA: int = Field(
default=50,
ge=1,
description="Claude daily generation request hard limit",
)
CLAUDE_RPM_LIMIT: int = Field(
default=2,
ge=1,
description="Claude generation requests per minute hard limit",
)
CLAUDE_DAILY_TOKEN_LIMIT: int = Field(
default=100_000,
ge=1,
description="Claude daily token hard limit including pending reservations",
)
CLAUDE_DAILY_COST_LIMIT_USD: float = Field(
default=1.0,
gt=0,
description="Claude daily spend hard limit in USD",
)
CLAUDE_TOTAL_COST_LIMIT_USD: float = Field(
default=5.0,
gt=0,
description="Claude cumulative spend hard limit in USD",
)
CLAUDE_COST_ALERT_THRESHOLD_USD: float = Field(
default=4.0,
gt=0,
description="Claude cumulative spend warning threshold in USD",
)
CLAUDE_MAX_OUTPUT_TOKENS: int = Field(
default=4096,
ge=1,
le=16_384,
description="Claude output ceiling and pre-call cost reservation basis",
)
CLAUDE_USAGE_RECEIPT_TTL_SECONDS: int = Field(
default=604_800,
ge=86_400,
description=(
"Claude non-secret auth-status receipt TTL; generation and run "
"identity receipts remain durable"
),
)
CLAUDE_FAILURE_COOLDOWN_SECONDS: int = Field(
default=120,
ge=1,
le=3600,
description="Claude failed generation work-item cooldown seconds",
)
CLAUDE_EXECUTION_MODE: str = Field(
default="shadow",
pattern="^(shadow|canary|production)$",
description=(
"Claude route mode: shadow performs no API call; canary uses a "
"deterministic run bucket; production enables the ordered fallback"
),
)
CLAUDE_CANARY_PERCENT: int = Field(
default=0,
ge=0,
le=100,
description="Deterministic Claude canary percentage when mode=canary",
)
# Deprecated: use OPENCLAW_URL instead
CLAWBOT_URL: str = Field(
default="http://192.168.0.188:8088", # 🔧 修正: OpenClaw 實際 port 是 8088
@@ -588,14 +660,25 @@ class Settings(BaseSettings):
# ==========================================================================
# Runtime fallback strategy. Alert/incident order is enforced as:
# GCP-A -> GCP-B -> host111 Ollama -> Gemini. No paid/provider insertion.
# GCP-A -> GCP-B -> host111 Ollama -> Claude -> Gemini.
# ==========================================================================
AI_FALLBACK_ORDER: list[str] = Field(
default=["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini"],
default=[
"ollama_gcp_a",
"ollama_gcp_b",
"ollama_local",
"claude",
"gemini",
],
description=(
"唯一 production provider chain: GCP-A -> GCP-B -> host111 -> Gemini"
"唯一 production provider chain: GCP-A -> GCP-B -> host111 -> "
"Claude -> Gemini"
),
)
ENABLE_CLAUDE: bool = Field(
default=False,
description="Claude paid fallback explicit opt-in; default disabled",
)
ENABLE_GEMINI: bool = Field(
default=False,
description="Gemini paid final fallback explicit opt-in; default disabled",
@@ -615,30 +698,30 @@ class Settings(BaseSettings):
description=(
"Allow incident/alert OpenClaw analysis to use cloud fallback "
"providers after the GCP-A/GCP-B/111 Ollama lane is exhausted. "
"Default true so Gemini can act as the final backup, after the "
"ordered Ollama lane is exhausted."
"Default true so Claude and then Gemini can act as paid backups "
"after the ordered Ollama lane is exhausted."
),
)
ALERT_AI_ENFORCE_OLLAMA_FIRST: bool = Field(
default=True,
description=(
"Force incident/alert OpenClaw analysis to try GCP-A, then GCP-B, "
"then local 111 before cloud backup providers such as Gemini."
"then local 111 before cloud backup providers Claude and Gemini."
),
)
ALERT_AI_STRICT_PROVIDER_CHAIN: bool = Field(
default=True,
description=(
"Forbid direct OpenClaw/Nemo/NVIDIA/Claude incident delegation; "
"the only alert route is GCP-A, GCP-B, host111, then Gemini."
"Forbid direct OpenClaw/Nemo/NVIDIA incident delegation; the only "
"alert route is GCP-A, GCP-B, host111, Claude, then Gemini."
),
)
ALERT_OLLAMA_MODEL: str = Field(
default="qwen3:14b",
description=(
"Ollama model used for incident/alert deep diagnosis. Alert cards "
"may wait for this model; Gemini remains a backup after GCP-A, "
"GCP-B, and 111 fail."
"may wait for this model; Claude then Gemini remain paid backups "
"after GCP-A, GCP-B, and 111 fail."
),
)
INCIDENT_LLM_TIMEOUT_SECONDS: int = Field(
@@ -670,9 +753,9 @@ class Settings(BaseSettings):
def parse_ai_fallback(cls, v: str | list[str]) -> list[str]:
"""
解析 AI_FALLBACK_ORDER支援三種格式:
1. JSON: '["ollama_gcp_a","ollama_gcp_b","ollama_local","gemini"]'
2. CSV: 'ollama_gcp_a,ollama_gcp_b,ollama_local,gemini'
3. List: ["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini"]
1. JSON: '["ollama_gcp_a","ollama_gcp_b","ollama_local","claude","gemini"]'
2. CSV: 'ollama_gcp_a,ollama_gcp_b,ollama_local,claude,gemini'
3. List: ["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "claude", "gemini"]
2026-03-27 修復: ConfigMap 用 JSON 格式,原本只支援 CSV
"""
@@ -692,13 +775,19 @@ class Settings(BaseSettings):
else:
values = [p.strip().lower() for p in v]
expected = ["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini"]
if values == ["ollama", "gemini"]:
expected = [
"ollama_gcp_a",
"ollama_gcp_b",
"ollama_local",
"claude",
"gemini",
]
if values in (["ollama", "gemini"], ["ollama", "claude", "gemini"]):
return expected
if values != expected:
raise ValueError(
"AI_FALLBACK_ORDER must be exactly "
"ollama_gcp_a,ollama_gcp_b,ollama_local,gemini"
"ollama_gcp_a,ollama_gcp_b,ollama_local,claude,gemini"
)
return values