Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m38s
CD Pipeline / build-and-deploy (push) Failing after 24m19s
CD Pipeline / post-deploy-checks (push) Has been skipped
414 lines
14 KiB
Python
414 lines
14 KiB
Python
"""
|
||
Gemini Provider - Phase 24 ADR-052
|
||
====================================
|
||
Google Gemini Cloud API (gemini-2.5-flash-lite)
|
||
|
||
搬移自: openclaw.py _call_gemini (L411-472)
|
||
特性: 最終付費備援、JSON 輸出、model-aware 原子成本閘
|
||
|
||
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,exception log 只保留 bounded code/type
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import structlog
|
||
|
||
from src.core.config import get_settings
|
||
from src.services.ai_providers.interfaces import (
|
||
AIResult,
|
||
bounded_cloud_output_tokens,
|
||
cloud_context_block_reason,
|
||
is_provider_enabled_by_env,
|
||
)
|
||
from src.services.model_registry import get_model_registry
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
settings = get_settings()
|
||
|
||
|
||
def classify_gemini_provider_error(error: Exception) -> str:
|
||
"""Return a bounded, non-secret receipt code for a provider failure."""
|
||
if isinstance(error, httpx.HTTPStatusError):
|
||
status_code = error.response.status_code
|
||
if status_code in {401, 403}:
|
||
return "authentication_rejected"
|
||
if status_code == 400:
|
||
try:
|
||
payload = error.response.json()
|
||
error_payload = (
|
||
payload.get("error", {}) if isinstance(payload, dict) else {}
|
||
)
|
||
markers: list[str] = [
|
||
str(error_payload.get("status", "")),
|
||
str(error_payload.get("message", "")),
|
||
]
|
||
for detail in error_payload.get("details", []) or []:
|
||
if isinstance(detail, dict):
|
||
markers.extend(
|
||
str(detail.get(key, "")) for key in ("reason", "status")
|
||
)
|
||
joined = " ".join(markers).upper()
|
||
if "API_KEY_INVALID" in joined or "API KEY NOT VALID" in joined:
|
||
return "authentication_rejected"
|
||
except Exception:
|
||
pass
|
||
return f"http_{status_code}"
|
||
return type(error).__name__[:80]
|
||
|
||
|
||
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
|
||
mode = str(getattr(settings, "GEMINI_EXECUTION_MODE", "shadow")).strip().lower()
|
||
mode_enabled = mode == "production" or (
|
||
mode == "canary" and int(getattr(settings, "GEMINI_CANARY_PERCENT", 0)) > 0
|
||
)
|
||
return is_provider_enabled_by_env("gemini") and mode_enabled
|
||
|
||
@property
|
||
def capabilities(self) -> set[str]:
|
||
return {"rca", "chat"}
|
||
|
||
@property
|
||
def privacy_level(self) -> str:
|
||
return "cloud"
|
||
|
||
@staticmethod
|
||
def _execution_mode_gate(
|
||
context: dict[str, Any] | None,
|
||
) -> tuple[bool, str, str, int | None]:
|
||
mode = str(getattr(settings, "GEMINI_EXECUTION_MODE", "shadow")).strip().lower()
|
||
if mode == "production":
|
||
return True, "production_route", mode, None
|
||
if mode == "shadow":
|
||
return False, "gemini_shadow_metadata_only", mode, None
|
||
if mode != "canary":
|
||
return False, "gemini_execution_mode_invalid", mode, None
|
||
|
||
percent = max(
|
||
0,
|
||
min(100, int(getattr(settings, "GEMINI_CANARY_PERCENT", 0))),
|
||
)
|
||
run_id = str((context or {}).get("run_id") or "")
|
||
if percent <= 0 or not run_id:
|
||
return False, "gemini_canary_not_selected", mode, None
|
||
bucket = int(hashlib.sha256(run_id.encode("utf-8")).hexdigest()[:8], 16) % 100
|
||
return (
|
||
bucket < percent,
|
||
(
|
||
"gemini_canary_selected"
|
||
if bucket < percent
|
||
else "gemini_canary_not_selected"
|
||
),
|
||
mode,
|
||
bucket,
|
||
)
|
||
|
||
@staticmethod
|
||
def _audit_metadata(
|
||
*,
|
||
receipt_id: str | None = None,
|
||
pricing_version: str | None = None,
|
||
execution_mode: str | None = None,
|
||
canary_bucket: int | None = None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"schema_version": "paid_provider_audit_v1",
|
||
"paid_provider": True,
|
||
"provider": "gemini",
|
||
"fallback_position": 5,
|
||
"execution_mode": execution_mode
|
||
or str(getattr(settings, "GEMINI_EXECUTION_MODE", "shadow"))
|
||
.strip()
|
||
.lower(),
|
||
"generation_receipt_id": receipt_id,
|
||
"pricing_version": pricing_version,
|
||
"canary_bucket": canary_bucket,
|
||
}
|
||
|
||
async def analyze(
|
||
self,
|
||
prompt: str,
|
||
context: dict | None = None,
|
||
) -> AIResult:
|
||
allowed, reason, mode, bucket = self._execution_mode_gate(context)
|
||
if not allowed:
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error=reason,
|
||
audit_metadata=self._audit_metadata(
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
),
|
||
)
|
||
privacy_block = cloud_context_block_reason(context)
|
||
if privacy_block:
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error=privacy_block,
|
||
audit_metadata=self._audit_metadata(
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
),
|
||
)
|
||
if not settings.GEMINI_API_KEY:
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error="GEMINI_API_KEY not configured",
|
||
audit_metadata=self._audit_metadata(
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
),
|
||
)
|
||
if not is_provider_enabled_by_env("gemini"):
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error="gemini_disabled_by_environment",
|
||
audit_metadata=self._audit_metadata(
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
),
|
||
)
|
||
|
||
try:
|
||
from src.services.ai_control import is_provider_disabled
|
||
|
||
if await is_provider_disabled(
|
||
"gemini",
|
||
run_id=str((context or {}).get("run_id") or ""),
|
||
):
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error="gemini_disabled_by_runtime_control",
|
||
audit_metadata=self._audit_metadata(
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
),
|
||
)
|
||
except Exception:
|
||
# Paid fallback is fail-closed when its durable disable-state
|
||
# cannot be proven. No reservation or HTTP call is made.
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error="gemini_disable_state_unavailable",
|
||
audit_metadata=self._audit_metadata(
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
),
|
||
)
|
||
|
||
from src.services.ai_rate_limiter import get_ai_rate_limiter
|
||
|
||
rate_limiter = get_ai_rate_limiter()
|
||
model_name = settings.GEMINI_MODEL
|
||
max_output_tokens = bounded_cloud_output_tokens(
|
||
settings.GEMINI_MAX_OUTPUT_TOKENS,
|
||
context,
|
||
)
|
||
reservation = await rate_limiter.reserve_generation(
|
||
"gemini",
|
||
prompt,
|
||
model=model_name,
|
||
max_output_tokens=max_output_tokens,
|
||
context=context,
|
||
)
|
||
audit = self._audit_metadata(
|
||
receipt_id=reservation.receipt_id,
|
||
pricing_version=reservation.pricing_version,
|
||
execution_mode=mode,
|
||
canary_bucket=bucket,
|
||
)
|
||
if not reservation.allowed:
|
||
logger.warning(
|
||
"gemini_provider_cost_guard_blocked",
|
||
receipt_id=reservation.receipt_id,
|
||
reason=reservation.reason,
|
||
)
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
error=f"cost_guard_blocked:{reservation.reason}",
|
||
audit_metadata=audit,
|
||
)
|
||
|
||
start = time.perf_counter()
|
||
try:
|
||
client = await self._get_client()
|
||
registry = get_model_registry()
|
||
|
||
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": max_output_tokens,
|
||
"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)
|
||
candidate_tokens = int(usage.get("candidatesTokenCount", 0) or 0)
|
||
# Gemini 2.5 bills internal thinking as output tokens. The API
|
||
# reports those separately from candidatesTokenCount, so both must
|
||
# be finalized and charged as completion/output usage.
|
||
thought_tokens = int(usage.get("thoughtsTokenCount", 0) or 0)
|
||
completion_tokens = candidate_tokens + thought_tokens
|
||
reported_total_tokens = int(usage.get("totalTokenCount", 0) or 0)
|
||
total_tokens = max(
|
||
reported_total_tokens,
|
||
int(prompt_tokens or 0) + completion_tokens,
|
||
)
|
||
cost_usd = reservation.actual_cost_usd(
|
||
int(prompt_tokens or 0),
|
||
completion_tokens,
|
||
)
|
||
latency = (time.perf_counter() - start) * 1000
|
||
|
||
finalized = await rate_limiter.finalize_generation(
|
||
reservation,
|
||
success=True,
|
||
prompt_tokens=prompt_tokens,
|
||
completion_tokens=completion_tokens,
|
||
)
|
||
if not finalized:
|
||
logger.error(
|
||
"gemini_provider_accounting_finalize_failed",
|
||
receipt_id=reservation.receipt_id,
|
||
)
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
latency_ms=latency,
|
||
error="gemini_accounting_finalize_failed",
|
||
audit_metadata=audit,
|
||
)
|
||
|
||
# Missing usageMetadata is explicitly estimated, never exposed as a
|
||
# successful zero-token/zero-cost call.
|
||
if total_tokens <= 0 or cost_usd <= 0:
|
||
total_tokens = (
|
||
reservation.estimated_prompt_tokens
|
||
+ reservation.estimated_completion_tokens
|
||
)
|
||
cost_usd = reservation.estimated_cost_usd
|
||
|
||
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,
|
||
audit_metadata=audit,
|
||
)
|
||
|
||
except Exception as error:
|
||
latency = (time.perf_counter() - start) * 1000
|
||
error_code = classify_gemini_provider_error(error)
|
||
finalized = await rate_limiter.finalize_generation(
|
||
reservation,
|
||
success=False,
|
||
error_code=error_code,
|
||
)
|
||
logger.warning(
|
||
"gemini_provider_failed",
|
||
error_code=error_code,
|
||
error_type=type(error).__name__[:80],
|
||
latency_ms=round(latency, 1),
|
||
receipt_id=reservation.receipt_id,
|
||
receipt_finalized=finalized,
|
||
)
|
||
return AIResult(
|
||
raw_response="",
|
||
success=False,
|
||
provider=self.name,
|
||
latency_ms=latency,
|
||
error=error_code,
|
||
audit_metadata=audit,
|
||
)
|
||
|
||
async def health_check(self) -> bool:
|
||
if not settings.GEMINI_API_KEY or not self.is_enabled:
|
||
return False
|
||
try:
|
||
from src.services.ai_rate_limiter import get_ai_rate_limiter
|
||
|
||
authentication = (
|
||
await get_ai_rate_limiter().get_provider_authentication_status("gemini")
|
||
)
|
||
return bool(
|
||
authentication.get("authenticated") is True
|
||
and authentication.get("authentication_verified") is True
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
async def close(self) -> None:
|
||
if self._http_client:
|
||
await self._http_client.aclose()
|
||
self._http_client = None
|