feat(ai): Gemini 優先 + Token/Cost 追蹤 (2026-03-29 ogt)

變更:
1. ConfigMap: Gemini 優先 ["gemini","ollama","claude"]
2. openclaw.py: 捕獲 Gemini usageMetadata (tokens/cost)
3. webhooks.py: 傳遞 ai_tokens/ai_cost 到 Telegram
4. telegram_gateway.py: 顯示 💰 Tokens: X / $Y.YYYY

Gemini 1.5 Flash 定價:
- Input: $0.075/1M tokens
- Output: $0.30/1M tokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-28 22:18:24 +08:00
parent 541565de48
commit 984d31de0c
4 changed files with 106 additions and 26 deletions

View File

@@ -159,6 +159,9 @@ async def _push_to_telegram_background(
signoz_rps: float = 0.0,
signoz_rps_trend: str = "stable",
signoz_error_rate: float = 0.0,
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens: int = 0,
ai_cost: float = 0.0,
signoz_p99_latency: float = 0.0,
signoz_latency_trend: str = "stable",
signoz_trace_url: str = "",
@@ -206,6 +209,9 @@ async def _push_to_telegram_background(
signoz_latency_trend=signoz_latency_trend,
signoz_trace_url=signoz_trace_url,
auto_tuning_command=auto_tuning_command,
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens=ai_tokens,
ai_cost=ai_cost,
)
logger.info(
@@ -216,6 +222,8 @@ async def _push_to_telegram_background(
primary_responsibility=primary_responsibility,
confidence=confidence,
signoz_integrated=signoz_rps > 0 or signoz_error_rate > 0,
ai_tokens=ai_tokens,
ai_cost=f"${ai_cost:.6f}",
)
except TelegramGatewayError as e:
@@ -887,8 +895,9 @@ async def receive_alert(
}
# 呼叫 OpenClaw LLM 分析 (v7.0 含 SignOz 整合)
# 2026-03-29 ogt: 加入 Token/Cost 追蹤
openclaw = get_openclaw()
analysis_result, ai_provider, raw_response, signoz_metrics, signoz_trace_url = await openclaw.analyze_alert(alert_context)
analysis_result, ai_provider, raw_response, signoz_metrics, signoz_trace_url, ai_tokens, ai_cost = await openclaw.analyze_alert(alert_context)
if analysis_result:
# LLM 分析成功
@@ -1017,6 +1026,9 @@ async def receive_alert(
signoz_latency_trend=signoz_latency_trend,
signoz_trace_url=signoz_trace_url,
auto_tuning_command=auto_tuning_cmd,
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens=ai_tokens,
ai_cost=ai_cost,
)
return AlertResponse(
@@ -1251,8 +1263,9 @@ async def alertmanager_webhook(
"labels": alert.labels,
}
# 2026-03-29 ogt: 加入 Token/Cost 追蹤
openclaw = get_openclaw()
analysis_result, ai_provider, raw_response, signoz_metrics, signoz_trace_url = await openclaw.analyze_alert(alert_context)
analysis_result, ai_provider, raw_response, signoz_metrics, signoz_trace_url, ai_tokens, ai_cost = await openclaw.analyze_alert(alert_context)
if analysis_result:
# analysis_result 是 OpenClawDecision Pydantic 模型
@@ -1335,6 +1348,9 @@ async def alertmanager_webhook(
signoz_p99_latency=signoz_metrics.p99_latency_ms if signoz_metrics else 0,
signoz_latency_trend=signoz_metrics.latency_trend if signoz_metrics else "stable",
signoz_trace_url=signoz_trace_url or "",
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens=ai_tokens,
ai_cost=ai_cost,
)
return AlertResponse(

View File

@@ -320,12 +320,21 @@ class OpenClawService:
)
return str(e), False
async def _call_gemini(self, prompt: str) -> tuple[str, bool]:
async def _call_gemini(self, prompt: str) -> tuple[str, bool, int, float]:
"""
呼叫 Google Gemini (支援 JSON Mode)
Returns:
tuple: (response_text, success, total_tokens, cost_usd)
- response_text: LLM 回應文本
- success: 是否成功
- total_tokens: 使用的 Token 總數
- cost_usd: 預估成本 (USD)
2026-03-29 ogt: 加入 Token/Cost 追蹤
"""
if not settings.GEMINI_API_KEY:
return "GEMINI_API_KEY not configured", False
return "GEMINI_API_KEY not configured", False, 0, 0.0
try:
client = await self._get_client()
@@ -350,12 +359,29 @@ class OpenClawService:
data = response.json()
text = data["candidates"][0]["content"]["parts"][0]["text"]
logger.info("gemini_response_received", response_length=len(text))
return text, True
# 2026-03-29 ogt: 擷取 Token 使用量
usage_metadata = data.get("usageMetadata", {})
prompt_tokens = usage_metadata.get("promptTokenCount", 0)
completion_tokens = usage_metadata.get("candidatesTokenCount", 0)
total_tokens = usage_metadata.get("totalTokenCount", prompt_tokens + completion_tokens)
# Gemini 1.5 Flash 定價 (per 1M tokens)
# Input: $0.075 / 1M, Output: $0.30 / 1M
cost_usd = (prompt_tokens * 0.000000075) + (completion_tokens * 0.0000003)
logger.info(
"gemini_response_received",
response_length=len(text),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost_usd=f"${cost_usd:.6f}",
)
return text, True, total_tokens, cost_usd
except Exception as e:
logger.warning("gemini_call_failed", error=str(e))
return str(e), False
return str(e), False, 0, 0.0
async def _call_claude(self, prompt: str) -> tuple[str, bool]:
"""
@@ -672,7 +698,7 @@ class OpenClawService:
alert_context: dict | None = None,
signoz_metrics: GoldMetrics | None = None,
cache_ttl: int = 3600, # 1 hour default
) -> tuple[str, str, bool, bool]:
) -> tuple[str, str, bool, bool, int, float]:
"""
帶快取的 LLM 呼叫包裝器
@@ -685,7 +711,9 @@ class OpenClawService:
cache_ttl: 快取存活時間 (秒)
Returns:
(response, provider, success, from_cache)
(response, provider, success, from_cache, total_tokens, cost_usd)
2026-03-29 ogt: 加入 Token/Cost 追蹤
"""
# 生成快取鍵 (基於 prompt + alert_context hash)
context_hash = ""
@@ -711,13 +739,15 @@ class OpenClawService:
f"{cached_data['provider']}_cached",
True,
True, # from_cache
0, # tokens (cache hit, no new tokens)
0.0, # cost (cache hit, no cost)
)
except Exception as e:
logger.warning("llm_cache_read_failed", error=str(e))
# 2. Cache Miss - 呼叫 LLM
logger.info("llm_cache_miss", cache_key=cache_key[:20])
response, provider, success = await self._call_with_fallback(
response, provider, success, total_tokens, cost_usd = await self._call_with_fallback(
prompt, alert_context, signoz_metrics
)
@@ -744,7 +774,7 @@ class OpenClawService:
except Exception as e:
logger.warning("llm_cache_write_failed", error=str(e))
return response, provider, success, False # from_cache=False
return response, provider, success, False, total_tokens, cost_usd # from_cache=False
# =========================================================================
# Public LLM Interface (ILLMProvider Protocol)
@@ -773,19 +803,23 @@ class OpenClawService:
prompt: str,
alert_context: dict | None = None,
signoz_metrics: GoldMetrics | None = None,
) -> tuple[str, str, bool]:
) -> tuple[str, str, bool, int, float]:
"""
依 AI_FALLBACK_ORDER 順序呼叫 AI
若 MOCK_MODE=True直接回傳模擬結果。
若所有 Provider 失敗fallback 到 Mock。
Returns:
tuple: (response, provider, success, total_tokens, cost_usd)
Phase 15.1: 整合 Langfuse LLMOps 追蹤
2026-03-29 ogt: 加入 Token/Cost 追蹤
"""
# Mock Mode: 開發測試用
if settings.MOCK_MODE:
logger.info("mock_mode_enabled", using="mock_llm")
return self._generate_mock_response(alert_context or {}, signoz_metrics), "mock", True
return self._generate_mock_response(alert_context or {}, signoz_metrics), "mock", True, 0, 0.0
# Phase 15.1 + 15.3: Langfuse 追蹤整合 + SignOz Deep Linking
with langfuse_trace(
@@ -833,10 +867,14 @@ class OpenClawService:
start_time = time.time()
model_name = self._get_model_name(provider)
# 2026-03-29 ogt: Gemini 回傳 4 值 (含 token/cost),其他 Provider 補 0
total_tokens = 0
cost_usd = 0.0
if provider == "ollama":
response, success = await self._call_ollama(prompt)
elif provider == "gemini":
response, success = await self._call_gemini(prompt)
response, success, total_tokens, cost_usd = await self._call_gemini(prompt)
elif provider == "claude":
response, success = await self._call_claude(prompt)
else:
@@ -855,21 +893,29 @@ class OpenClawService:
"success": success,
"latency_ms": round(latency_ms, 2),
"provider": provider,
"total_tokens": total_tokens,
"cost_usd": cost_usd,
},
)
if success:
logger.info("ai_provider_success", provider=provider, latency_ms=latency_ms)
logger.info(
"ai_provider_success",
provider=provider,
latency_ms=latency_ms,
total_tokens=total_tokens,
cost_usd=f"${cost_usd:.6f}",
)
# Langfuse: 記錄成功評分
trace.score(name="provider_success", value=1.0, comment=f"Success via {provider}")
return response, provider, True
return response, provider, True, total_tokens, cost_usd
logger.warning("ai_provider_failed_fallback", provider=provider, latency_ms=latency_ms)
# 所有 Provider 失敗時fallback 到 Mock (優雅降級)
logger.warning("all_providers_failed_using_mock", fallback="mock_llm")
trace.score(name="provider_success", value=0.0, comment="All providers failed, using mock")
return self._generate_mock_response(alert_context or {}, signoz_metrics), "mock_fallback", True
return self._generate_mock_response(alert_context or {}, signoz_metrics), "mock_fallback", True, 0, 0.0
def _get_model_name(self, provider: str) -> str:
"""取得 provider 對應的模型名稱 (從 ModelRegistry)"""
@@ -977,7 +1023,7 @@ class OpenClawService:
async def analyze_alert(
self,
alert_context: dict,
) -> tuple[LLMAnalysisResult | None, str, str, GoldMetrics | None, str]:
) -> tuple[LLMAnalysisResult | None, str, str, GoldMetrics | None, str, int, float]:
"""
分析告警並產生 RCA 結果 (含 SignOz 整合)
@@ -985,7 +1031,9 @@ class OpenClawService:
alert_context: 告警上下文 (alert_type, severity, target_resource, etc.)
Returns:
(analysis_result, ai_provider, raw_response, signoz_metrics, signoz_trace_url)
(analysis_result, ai_provider, raw_response, signoz_metrics, signoz_trace_url, total_tokens, cost_usd)
2026-03-29 ogt: 加入 Token/Cost 追蹤
"""
# Step 0: 擷取 SignOz 上下文
service_name = alert_context.get("target_resource", "unknown")
@@ -1018,7 +1066,7 @@ Trace URL: {signoz_trace_url}
)
# 呼叫 LLM (使用快取層保護算力)
raw_response, provider, success, from_cache = await self._call_with_cache(
raw_response, provider, success, from_cache, total_tokens, cost_usd = await self._call_with_cache(
full_prompt,
alert_context,
signoz_metrics,
@@ -1027,7 +1075,7 @@ Trace URL: {signoz_trace_url}
if not success:
logger.error("openclaw_all_providers_failed")
return None, provider, raw_response, signoz_metrics, signoz_trace_url
return None, provider, raw_response, signoz_metrics, signoz_trace_url, 0, 0.0
if from_cache:
logger.info("openclaw_using_cached_response", provider=provider)
@@ -1056,7 +1104,7 @@ Trace URL: {signoz_trace_url}
raw_response=raw_response[:300],
)
return result, provider, raw_response, signoz_metrics, signoz_trace_url
return result, provider, raw_response, signoz_metrics, signoz_trace_url, total_tokens, cost_usd
# =========================================================================
# Phase 6.4: LLM Proposal Generation

View File

@@ -139,6 +139,9 @@ class TelegramMessage:
signoz_metrics: SignOzMetricsBlock | None = None
signoz_trace_url: str = "" # 動態時間參數 URL
auto_tuning_command: str = "" # kubectl 調優指令
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens: int = 0 # LLM Token 使用量
ai_cost: float = 0.0 # LLM 成本 (USD)
def format(self) -> str:
"""
@@ -190,6 +193,11 @@ class TelegramMessage:
safe_action = html.escape(self.suggested_action[:35])
safe_downtime = html.escape(self.estimated_downtime)
# 2026-03-29 ogt: AI Token/Cost 顯示
ai_cost_display = ""
if self.ai_tokens > 0 or self.ai_cost > 0:
ai_cost_display = f"💰 Tokens: {self.ai_tokens:,} / ${self.ai_cost:.4f}\n"
# 組裝訊息
message = (
f"═══════════════════════════\n"
@@ -201,6 +209,7 @@ class TelegramMessage:
f"🤖 <b>AI 仲裁判定</b>\n"
f"👥 責任: {resp_display}\n"
f"📊 信心: {conf_emoji} {confidence_pct}%\n"
f"{ai_cost_display}"
f"💡 原因: {safe_root_cause}\n"
f"{signoz_block}"
f"━━━━━━━━━━━━━━━━━━━\n"
@@ -425,6 +434,9 @@ class TelegramGateway:
signoz_latency_trend: str = "stable",
signoz_trace_url: str = "",
auto_tuning_command: str = "",
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens: int = 0,
ai_cost: float = 0.0,
) -> dict:
"""
推送待簽核卡片到 Telegram (v7.0 含 SignOz 整合)
@@ -463,7 +475,7 @@ class TelegramGateway:
trace_url=signoz_trace_url,
)
# 建立訊息結構 (含 AI 仲裁 + SignOz)
# 建立訊息結構 (含 AI 仲裁 + SignOz + Token/Cost)
message = TelegramMessage(
status_emoji=emoji,
risk_level=risk_level.upper(),
@@ -478,6 +490,9 @@ class TelegramGateway:
signoz_metrics=signoz_metrics,
signoz_trace_url=signoz_trace_url,
auto_tuning_command=auto_tuning_command,
# 2026-03-29 ogt: AI Token/Cost 追蹤
ai_tokens=ai_tokens,
ai_cost=ai_cost,
)
# 格式化訊息