feat(ai): add guarded Claude fallback route
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,38 +10,29 @@ Google Gemini Cloud API (gemini-2.5-flash-lite)
|
||||
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 當防線
|
||||
修法:改用 x-goog-api-key header,exception log 只保留 bounded code/type
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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, is_provider_enabled_by_env
|
||||
from src.services.ai_providers.interfaces import (
|
||||
AIResult,
|
||||
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()
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
def classify_gemini_provider_error(error: Exception) -> str:
|
||||
"""Return a bounded, non-secret receipt code for a provider failure."""
|
||||
if isinstance(error, httpx.HTTPStatusError):
|
||||
@@ -105,19 +96,51 @@ class GeminiProvider:
|
||||
def privacy_level(self) -> str:
|
||||
return "cloud"
|
||||
|
||||
@staticmethod
|
||||
def _audit_metadata(
|
||||
*,
|
||||
receipt_id: str | None = None,
|
||||
pricing_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "paid_provider_audit_v1",
|
||||
"paid_provider": True,
|
||||
"provider": "gemini",
|
||||
"fallback_position": 5,
|
||||
"execution_mode": "production",
|
||||
"generation_receipt_id": receipt_id,
|
||||
"pricing_version": pricing_version,
|
||||
}
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
prompt: str,
|
||||
context: dict | None = None,
|
||||
) -> AIResult:
|
||||
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(),
|
||||
)
|
||||
if not settings.GEMINI_API_KEY:
|
||||
return AIResult(raw_response="", success=False, provider=self.name, error="GEMINI_API_KEY not configured")
|
||||
return AIResult(
|
||||
raw_response="",
|
||||
success=False,
|
||||
provider=self.name,
|
||||
error="GEMINI_API_KEY not configured",
|
||||
audit_metadata=self._audit_metadata(),
|
||||
)
|
||||
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(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -129,6 +152,7 @@ class GeminiProvider:
|
||||
success=False,
|
||||
provider=self.name,
|
||||
error="gemini_disabled_by_runtime_control",
|
||||
audit_metadata=self._audit_metadata(),
|
||||
)
|
||||
except Exception:
|
||||
# Paid fallback is fail-closed when its durable disable-state
|
||||
@@ -138,6 +162,7 @@ class GeminiProvider:
|
||||
success=False,
|
||||
provider=self.name,
|
||||
error="gemini_disable_state_unavailable",
|
||||
audit_metadata=self._audit_metadata(),
|
||||
)
|
||||
|
||||
from src.services.ai_rate_limiter import get_ai_rate_limiter
|
||||
@@ -151,6 +176,10 @@ class GeminiProvider:
|
||||
max_output_tokens=settings.GEMINI_MAX_OUTPUT_TOKENS,
|
||||
context=context,
|
||||
)
|
||||
audit = self._audit_metadata(
|
||||
receipt_id=reservation.receipt_id,
|
||||
pricing_version=reservation.pricing_version,
|
||||
)
|
||||
if not reservation.allowed:
|
||||
logger.warning(
|
||||
"gemini_provider_cost_guard_blocked",
|
||||
@@ -162,6 +191,7 @@ class GeminiProvider:
|
||||
success=False,
|
||||
provider=self.name,
|
||||
error=f"cost_guard_blocked:{reservation.reason}",
|
||||
audit_metadata=audit,
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
@@ -223,6 +253,7 @@ class GeminiProvider:
|
||||
provider=self.name,
|
||||
latency_ms=latency,
|
||||
error="gemini_accounting_finalize_failed",
|
||||
audit_metadata=audit,
|
||||
)
|
||||
|
||||
# Missing usageMetadata is explicitly estimated, never exposed as a
|
||||
@@ -248,27 +279,33 @@ class GeminiProvider:
|
||||
tokens=total_tokens,
|
||||
cost_usd=cost_usd,
|
||||
latency_ms=latency,
|
||||
audit_metadata=audit,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except Exception as error:
|
||||
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)
|
||||
error_code = classify_gemini_provider_error(error)
|
||||
finalized = await rate_limiter.finalize_generation(
|
||||
reservation,
|
||||
success=False,
|
||||
error_code=classify_gemini_provider_error(e),
|
||||
error_code=error_code,
|
||||
)
|
||||
logger.warning(
|
||||
"gemini_provider_failed",
|
||||
error=safe_error,
|
||||
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=safe_error)
|
||||
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:
|
||||
|
||||
@@ -14,12 +14,130 @@ AI Provider Interfaces - Phase 24 ADR-052
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from src.plugins.mcp.interfaces import MCPTool
|
||||
|
||||
_CLOUD_SAFE_DATA_CLASSIFICATIONS = frozenset(
|
||||
{
|
||||
"non_sensitive",
|
||||
"operational_metadata",
|
||||
"public",
|
||||
"sanitized",
|
||||
}
|
||||
)
|
||||
_EXPLICIT_SENSITIVE_CONTEXT_KEYS = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"auth_header",
|
||||
"authorization",
|
||||
"authorization_header",
|
||||
"access_token",
|
||||
"bearer_token",
|
||||
"client_secret",
|
||||
"cookie",
|
||||
"contains_private_data",
|
||||
"contains_secret",
|
||||
"contains_secret_value",
|
||||
"contains_sensitive_context",
|
||||
"credential",
|
||||
"credentials",
|
||||
"credentials_present",
|
||||
"id_token",
|
||||
"passphrase",
|
||||
"password",
|
||||
"payload_contains_secret",
|
||||
"private_key",
|
||||
"refresh_token",
|
||||
"secret_value_exposed",
|
||||
"session",
|
||||
"session_id",
|
||||
"set_cookie",
|
||||
"sensitive_context",
|
||||
"token_value",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _canonical_context_key(value: Any) -> str:
|
||||
"""Normalize structured metadata keys without inspecting their values."""
|
||||
text = str(value).strip()
|
||||
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", text)
|
||||
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
|
||||
return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()
|
||||
|
||||
|
||||
def _root_context_value(context: dict[str, Any], key: str) -> Any:
|
||||
for raw_key, value in context.items():
|
||||
if _canonical_context_key(raw_key) == key:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _marker_is_present(value: Any) -> bool:
|
||||
"""Interpret a structured context marker conservatively without prompt DLP."""
|
||||
if value is None or value is False:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() not in {"", "0", "false", "no", "none", "absent"}
|
||||
if isinstance(value, list | tuple | set | dict):
|
||||
return bool(value)
|
||||
return bool(value)
|
||||
|
||||
|
||||
def cloud_context_block_reason(
|
||||
context: dict[str, Any] | None,
|
||||
*,
|
||||
require_local: bool = False,
|
||||
) -> str | None:
|
||||
"""Fail closed on explicit structured privacy markers for cloud providers.
|
||||
|
||||
This is an enforcement boundary for producer-supplied metadata, not a
|
||||
claim that arbitrary prompt text has been inspected or sanitized by DLP.
|
||||
Upstream normalizers must explicitly classify/redact content before using a
|
||||
paid cloud lane.
|
||||
"""
|
||||
root_context = context or {}
|
||||
if require_local or _marker_is_present(
|
||||
_root_context_value(root_context, "require_local")
|
||||
):
|
||||
return "cloud_context_requires_local"
|
||||
|
||||
classification = _root_context_value(root_context, "data_classification")
|
||||
if classification is None or not str(classification).strip():
|
||||
return "cloud_context_classification_missing"
|
||||
if str(classification).strip().lower() not in _CLOUD_SAFE_DATA_CLASSIFICATIONS:
|
||||
return "cloud_context_data_classification_blocked"
|
||||
|
||||
def _walk(value: Any) -> str | None:
|
||||
if isinstance(value, dict):
|
||||
for raw_key, child in value.items():
|
||||
key = _canonical_context_key(raw_key)
|
||||
if key == "data_classification" and child is not None:
|
||||
classification = str(child).strip().lower()
|
||||
if classification not in _CLOUD_SAFE_DATA_CLASSIFICATIONS:
|
||||
return "cloud_context_data_classification_blocked"
|
||||
if key == "require_local" and _marker_is_present(child):
|
||||
return "cloud_context_requires_local"
|
||||
if key in _EXPLICIT_SENSITIVE_CONTEXT_KEYS and _marker_is_present(child):
|
||||
return "cloud_context_sensitive_marker_blocked"
|
||||
if "raw_log" in key and _marker_is_present(child):
|
||||
return "cloud_context_raw_log_blocked"
|
||||
nested = _walk(child)
|
||||
if nested:
|
||||
return nested
|
||||
elif isinstance(value, list | tuple | set):
|
||||
for child in value:
|
||||
nested = _walk(child)
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
|
||||
return _walk(context or {})
|
||||
|
||||
# =============================================================================
|
||||
# AIResult — 標準化 AI 分析結果
|
||||
# =============================================================================
|
||||
@@ -42,6 +160,7 @@ class AIResult:
|
||||
cost_usd: float = 0.0
|
||||
latency_ms: float = 0.0
|
||||
error: str | None = None
|
||||
audit_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -53,6 +172,7 @@ class AIResult:
|
||||
"cost_usd": self.cost_usd,
|
||||
"latency_ms": round(self.latency_ms, 2),
|
||||
"error": self.error,
|
||||
"audit_metadata": self.audit_metadata,
|
||||
}
|
||||
|
||||
|
||||
@@ -194,5 +314,9 @@ def is_provider_enabled_by_env(provider_name: str) -> bool:
|
||||
bool: 是否啟用 (Gemini 預設 False,其他 provider 預設 True)
|
||||
"""
|
||||
env_key = f"ENABLE_{provider_name.upper()}"
|
||||
default = "false" if provider_name.strip().lower() == "gemini" else "true"
|
||||
default = (
|
||||
"false"
|
||||
if provider_name.strip().lower() in {"claude", "gemini"}
|
||||
else "true"
|
||||
)
|
||||
return os.getenv(env_key, default).lower() == "true"
|
||||
|
||||
Reference in New Issue
Block a user