Files
awoooi/apps/api/src/services/ai_providers/gemini.py
ogt 541a32a9cb
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
feat(sre): enforce typed controlled automation
2026-07-16 17:32:01 +08:00

414 lines
14 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.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 headerexception 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