Files
awoooi/apps/api/src/services/ai_providers/gemini.py
Your Name d845d53257
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 15m5s
fix(security): keep Gemini key out of request URLs
2026-04-29 22:56:12 +08:00

151 lines
5.3 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.
"""
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