151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
"""
|
||
Gemini Provider - Phase 24 ADR-052
|
||
====================================
|
||
Google Gemini Cloud API (gemini-2.0-flash)
|
||
|
||
搬移自: openclaw.py _call_gemini (L411-472)
|
||
特性: 穩定快速、JSON 輸出可靠、有費用 ($0.075/1M input)
|
||
|
||
2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出
|
||
2026-04-29 ogt + Claude Code: P0 SECRET LEAK 修復
|
||
發現 prod log 出現完整 API key 明碼(feedback_secret_debug_output_ban 鐵律)
|
||
根因:httpx HTTPStatusError str() 會包含完整 URL(含 ?key=... query string)
|
||
修法:改用 x-goog-api-key header,並保留 _sanitize_error 當防線
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import time
|
||
|
||
import httpx
|
||
import structlog
|
||
|
||
from src.core.config import get_settings
|
||
from src.services.ai_providers.interfaces import AIResult, is_provider_enabled_by_env
|
||
from src.services.model_registry import get_model_registry
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
settings = get_settings()
|
||
|
||
|
||
# 2026-04-29 ogt + Claude Code: P0 SECRET LEAK
|
||
# httpx exception str() 會把完整 URL 含 ?key=AIzaSy... 寫進 error message
|
||
# 此 redact 函式在所有 logger 出口先過濾,防止 K8s pod log / Sentry / Telegram
|
||
# 任何下游接收端看到 key 明碼
|
||
_KEY_REDACT_PATTERN = re.compile(r"([?&])key=[^&\s'\"]+")
|
||
|
||
|
||
def _sanitize_error(error: object) -> str:
|
||
"""從錯誤訊息移除 ?key=xxx 等敏感 query string"""
|
||
msg = str(error)
|
||
return _KEY_REDACT_PATTERN.sub(r"\1key=<redacted>", msg)
|
||
|
||
|
||
class GeminiProvider:
|
||
"""
|
||
Google Gemini Cloud Provider
|
||
|
||
privacy_level: cloud (禁止機密資料)
|
||
capabilities: rca, chat
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._http_client: httpx.AsyncClient | None = None
|
||
|
||
async def _get_client(self) -> httpx.AsyncClient:
|
||
if self._http_client is None or self._http_client.is_closed:
|
||
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0))
|
||
return self._http_client
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
return "gemini"
|
||
|
||
@property
|
||
def is_enabled(self) -> bool:
|
||
if not settings.GEMINI_API_KEY:
|
||
return False
|
||
return is_provider_enabled_by_env("gemini")
|
||
|
||
@property
|
||
def capabilities(self) -> set[str]:
|
||
return {"rca", "chat"}
|
||
|
||
@property
|
||
def privacy_level(self) -> str:
|
||
return "cloud"
|
||
|
||
async def analyze(
|
||
self,
|
||
prompt: str,
|
||
context: dict | None = None,
|
||
) -> AIResult:
|
||
if not settings.GEMINI_API_KEY:
|
||
return AIResult(raw_response="", success=False, provider=self.name, error="GEMINI_API_KEY not configured")
|
||
|
||
start = time.perf_counter()
|
||
try:
|
||
client = await self._get_client()
|
||
registry = get_model_registry()
|
||
model_name = registry.get_model("gemini", "rca")
|
||
|
||
response = await client.post(
|
||
f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent",
|
||
headers={"x-goog-api-key": settings.GEMINI_API_KEY},
|
||
json={
|
||
"contents": [{"parts": [{"text": prompt}]}],
|
||
"generationConfig": {
|
||
"temperature": registry.get_provider_options("gemini").get("temperature", 0.1),
|
||
"maxOutputTokens": 2048,
|
||
"responseMimeType": "application/json",
|
||
},
|
||
},
|
||
timeout=30.0,
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
||
|
||
# Token/Cost 追蹤
|
||
usage = data.get("usageMetadata", {})
|
||
prompt_tokens = usage.get("promptTokenCount", 0)
|
||
completion_tokens = usage.get("candidatesTokenCount", 0)
|
||
total_tokens = usage.get("totalTokenCount", prompt_tokens + completion_tokens)
|
||
# Gemini 1.5 Flash: Input $0.075/1M, Output $0.30/1M
|
||
cost_usd = (prompt_tokens * 0.000000075) + (completion_tokens * 0.0000003)
|
||
latency = (time.perf_counter() - start) * 1000
|
||
|
||
logger.info(
|
||
"gemini_provider_success",
|
||
response_length=len(text),
|
||
total_tokens=total_tokens,
|
||
cost_usd=f"${cost_usd:.6f}",
|
||
latency_ms=round(latency, 1),
|
||
)
|
||
return AIResult(
|
||
raw_response=text,
|
||
success=True,
|
||
provider=self.name,
|
||
tokens=total_tokens,
|
||
cost_usd=cost_usd,
|
||
latency_ms=latency,
|
||
)
|
||
|
||
except Exception as e:
|
||
latency = (time.perf_counter() - start) * 1000
|
||
# 2026-04-29 ogt + Claude Code: P0 SECRET LEAK 修復
|
||
# 之前 str(e) 會洩漏 URL 中的 ?key=AIzaSy... 到 prod log
|
||
# 現用 _sanitize_error 過濾 ?key= query string
|
||
safe_error = _sanitize_error(e)
|
||
logger.warning("gemini_provider_failed", error=safe_error, latency_ms=round(latency, 1))
|
||
return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=safe_error)
|
||
|
||
async def health_check(self) -> bool:
|
||
return bool(settings.GEMINI_API_KEY)
|
||
|
||
async def close(self) -> None:
|
||
if self._http_client:
|
||
await self._http_client.aclose()
|
||
self._http_client = None
|