feat(ai): add guarded Claude fallback route

This commit is contained in:
ogt
2026-07-15 15:57:24 +08:00
parent f57845ba5e
commit 5c5eb48091
56 changed files with 4942 additions and 760 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 headerexception 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:

View File

@@ -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"