diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py
index 0a26c16f..82de0bba 100644
--- a/hindsight-api/hindsight_api/config.py
+++ b/hindsight-api/hindsight_api/config.py
@@ -252,6 +252,9 @@ ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
+# Gemini safety settings
+ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS"
+
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
@@ -353,6 +356,9 @@ DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
DEFAULT_LLM_VERTEXAI_REGION = "us-central1"
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
+# Gemini safety settings defaults
+DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety settings
+
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
@@ -567,6 +573,9 @@ class HindsightConfig:
llm_vertexai_region: str
llm_vertexai_service_account_key: str | None
+ # Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
+ llm_gemini_safety_settings: list | None
+
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
@@ -792,6 +801,8 @@ class HindsightConfig:
"disposition_skepticism",
"disposition_literalism",
"disposition_empathy",
+ # Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
+ "llm_gemini_safety_settings",
}
@property
@@ -912,6 +923,8 @@ class HindsightConfig:
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
+ # Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
+ llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
index f5e8a845..e8bca7f3 100644
--- a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
+++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
@@ -126,6 +126,12 @@ async def run_consolidation_job(
"""
# Resolve bank-specific config with hierarchical overrides
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
+
+ # Apply bank-specific Gemini safety settings for this request context
+ from ..providers.gemini_llm import set_gemini_safety_settings
+
+ set_gemini_safety_settings(config.llm_gemini_safety_settings)
+
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
llm_batch_size = max(1, config.consolidation_llm_batch_size)
diff --git a/hindsight-api/hindsight_api/engine/llm_wrapper.py b/hindsight-api/hindsight_api/engine/llm_wrapper.py
index 86805070..2ec45d39 100644
--- a/hindsight-api/hindsight_api/engine/llm_wrapper.py
+++ b/hindsight-api/hindsight_api/engine/llm_wrapper.py
@@ -124,6 +124,7 @@ def create_llm_provider(
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
+ gemini_safety_settings: list | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -192,6 +193,7 @@ def create_llm_provider(
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
+ gemini_safety_settings=gemini_safety_settings,
)
elif provider_lower == "anthropic":
@@ -234,6 +236,7 @@ class LLMProvider:
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
+ gemini_safety_settings: list | None = None,
):
"""
Initialize LLM provider.
@@ -246,6 +249,7 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
+ gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
"""
self.provider = provider.lower()
self.api_key = api_key
@@ -255,6 +259,8 @@ class LLMProvider:
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
+ # Gemini safety settings (instance default; can be overridden per-request via context var)
+ self.gemini_safety_settings = gemini_safety_settings
# Validate provider
valid_providers = [
@@ -323,6 +329,18 @@ class LLMProvider:
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
)
+ # For Gemini/VertexAI providers: read safety settings from global config if not explicitly provided
+ # Use _get_raw_config() to bypass StaticConfigProxy (which blocks configurable fields),
+ # since LLMProvider initialization legitimately needs the server-level default.
+ if self.provider in ("gemini", "vertexai") and self.gemini_safety_settings is None:
+ from ..config import _get_raw_config
+
+ try:
+ raw_config = _get_raw_config()
+ self.gemini_safety_settings = raw_config.llm_gemini_safety_settings
+ except Exception:
+ pass # Config may not be initialized in test environments
+
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
provider=self.provider,
@@ -335,6 +353,7 @@ class LLMProvider:
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
+ gemini_safety_settings=self.gemini_safety_settings,
)
# Backward compatibility: Keep mock provider properties
diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py
index 0251f306..1dc7362a 100644
--- a/hindsight-api/hindsight_api/engine/memory_engine.py
+++ b/hindsight-api/hindsight_api/engine/memory_engine.py
@@ -1831,6 +1831,11 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
+ # Apply bank-specific Gemini safety settings for this request context
+ from .providers.gemini_llm import set_gemini_safety_settings
+
+ set_gemini_safety_settings(resolved_config.llm_gemini_safety_settings)
+
# Create parent span for retain operation
with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch(
@@ -4482,6 +4487,12 @@ class MemoryEngine(MemoryEngineInterface):
# The agent can call lookup() to list available models if needed.
# This is critical for banks with many mental models to avoid huge prompts.
+ # Apply bank-specific Gemini safety settings for this request context
+ resolved_reflect_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
+ from .providers.gemini_llm import set_gemini_safety_settings
+
+ set_gemini_safety_settings(resolved_reflect_config.llm_gemini_safety_settings)
+
# Compute max iterations based on budget
config = get_config()
base_max_iterations = config.reflect_max_iterations
diff --git a/hindsight-api/hindsight_api/engine/providers/gemini_llm.py b/hindsight-api/hindsight_api/engine/providers/gemini_llm.py
index f078c792..52a96274 100644
--- a/hindsight-api/hindsight_api/engine/providers/gemini_llm.py
+++ b/hindsight-api/hindsight_api/engine/providers/gemini_llm.py
@@ -11,6 +11,7 @@ import json
import logging
import os
import time
+from contextvars import ContextVar
from typing import Any
from google import genai
@@ -24,6 +25,25 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
+# Context variable for per-request Gemini safety settings override (supports per-bank configuration)
+_safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settings", default=None)
+
+
+def set_gemini_safety_settings(settings: list | None) -> None:
+ """
+ Set Gemini safety settings for the current async context.
+
+ This allows per-bank safety settings to be applied without changing
+ the LLM provider interface. Call this before making LLM calls within
+ an operation that has resolved bank-specific configuration.
+
+ Args:
+ settings: List of safety setting dicts with 'category' and 'threshold' keys,
+ or None to use the instance default (from env var).
+ """
+ _safety_settings_ctx.set(settings)
+
+
# Vertex AI imports (optional)
try:
import google.auth
@@ -58,6 +78,9 @@ class GeminiLLM(LLMInterface):
self._client = None
self._is_vertexai = self.provider == "vertexai"
+ # Safety settings: None means use Gemini's defaults
+ self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
+
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
@@ -216,6 +239,16 @@ class GeminiLLM(LLMInterface):
if temperature is not None:
config_kwargs["temperature"] = temperature
+ # Apply safety settings: context var (per-request bank override) takes precedence over instance default
+ effective_safety_settings = _safety_settings_ctx.get()
+ if effective_safety_settings is None:
+ effective_safety_settings = self._safety_settings
+ if effective_safety_settings is not None:
+ config_kwargs["safety_settings"] = [
+ genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
+ for s in effective_safety_settings
+ ]
+
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None
@@ -489,6 +522,16 @@ class GeminiLLM(LLMInterface):
)
# "auto" is the default (no tool_config needed)
+ # Apply safety settings: context var (per-request bank override) takes precedence over instance default
+ effective_safety_settings = _safety_settings_ctx.get()
+ if effective_safety_settings is None:
+ effective_safety_settings = self._safety_settings
+ if effective_safety_settings is not None:
+ config_kwargs["safety_settings"] = [
+ genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
+ for s in effective_safety_settings
+ ]
+
config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None
diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py
index 635a1caf..fb6bfeda 100644
--- a/hindsight-api/hindsight_api/main.py
+++ b/hindsight-api/hindsight_api/main.py
@@ -171,6 +171,7 @@ def main():
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
+ llm_gemini_safety_settings=config.llm_gemini_safety_settings,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,
diff --git a/hindsight-api/tests/test_gemini_safety_settings.py b/hindsight-api/tests/test_gemini_safety_settings.py
new file mode 100644
index 00000000..a8f8f984
--- /dev/null
+++ b/hindsight-api/tests/test_gemini_safety_settings.py
@@ -0,0 +1,325 @@
+"""
+Tests for Gemini safety settings feature.
+
+Verifies that:
+- Safety settings are read from env var and stored on GeminiLLM instances
+- Settings are applied to GenerateContentConfig in call() and call_with_tools()
+- The context variable override allows per-bank settings at request time
+- None (unset) means Gemini's default safety settings are used (no override)
+"""
+
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+pytest.importorskip("google.genai")
+
+
+SAMPLE_SAFETY_SETTINGS = [
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
+]
+
+
+# ─── Config / env var parsing ─────────────────────────────────────────────────
+
+
+def test_gemini_safety_settings_parsed_from_env():
+ """Safety settings JSON from env var is parsed into HindsightConfig."""
+ import json
+
+ from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache
+
+ settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS)
+ with patch.dict(os.environ, {ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json}, clear=False):
+ clear_config_cache()
+ config = HindsightConfig.from_env()
+ assert config.llm_gemini_safety_settings == SAMPLE_SAFETY_SETTINGS
+ clear_config_cache()
+
+
+def test_gemini_safety_settings_default_is_none():
+ """When env var is not set, llm_gemini_safety_settings defaults to None."""
+ from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache
+
+ env = {k: v for k, v in os.environ.items() if k != ENV_LLM_GEMINI_SAFETY_SETTINGS}
+ with patch.dict(os.environ, env, clear=True):
+ clear_config_cache()
+ config = HindsightConfig.from_env()
+ assert config.llm_gemini_safety_settings is None
+ clear_config_cache()
+
+
+def test_gemini_safety_settings_is_configurable_field():
+ """llm_gemini_safety_settings appears in configurable (per-bank) fields."""
+ from hindsight_api.config import HindsightConfig
+
+ assert "llm_gemini_safety_settings" in HindsightConfig.get_configurable_fields()
+
+
+def test_gemini_safety_settings_not_in_credential_fields():
+ """llm_gemini_safety_settings is NOT a credential — it is safe to expose via API."""
+ from hindsight_api.config import HindsightConfig
+
+ assert "llm_gemini_safety_settings" not in HindsightConfig.get_credential_fields()
+
+
+# ─── GeminiLLM instance ───────────────────────────────────────────────────────
+
+
+def _make_gemini_provider(safety_settings=None):
+ """Return a GeminiLLM instance with a mocked genai.Client."""
+ with patch("google.genai.Client") as mock_client_cls:
+ mock_client_cls.return_value = MagicMock()
+ from hindsight_api.engine.providers.gemini_llm import GeminiLLM
+
+ provider = GeminiLLM(
+ provider="gemini",
+ api_key="fake-api-key",
+ base_url="",
+ model="gemini-2.5-flash",
+ gemini_safety_settings=safety_settings,
+ )
+ # Replace client with a fresh mock so we can inspect calls
+ provider._client = MagicMock()
+ return provider
+
+
+def test_gemini_llm_stores_safety_settings():
+ """GeminiLLM stores safety settings passed at construction."""
+ provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
+ assert provider._safety_settings == SAMPLE_SAFETY_SETTINGS
+
+
+def test_gemini_llm_no_safety_settings_is_none():
+ """GeminiLLM._safety_settings is None when not provided."""
+ provider = _make_gemini_provider(safety_settings=None)
+ assert provider._safety_settings is None
+
+
+# ─── call() applies safety settings ──────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_call_applies_safety_settings():
+ """call() includes safety_settings in GenerateContentConfig when configured."""
+ from google.genai import types as genai_types
+
+ provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
+
+ # Build a fake successful response
+ fake_response = MagicMock()
+ fake_response.text = "hello"
+ fake_response.candidates = [MagicMock(finish_reason="STOP")]
+ fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
+
+ provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
+
+ await provider.call(
+ messages=[{"role": "user", "content": "hi"}],
+ scope="test",
+ )
+
+ # Inspect the config passed to generate_content
+ call_args = provider._client.aio.models.generate_content.call_args
+ config_arg = call_args.kwargs.get("config") or call_args.args[0] if call_args.args else None
+ # config may be in kwargs or positional; grab from kwargs
+ config_arg = call_args.kwargs.get("config")
+
+ assert config_arg is not None, "GenerateContentConfig should have been passed"
+ assert hasattr(config_arg, "safety_settings"), "Config should have safety_settings"
+ assert config_arg.safety_settings is not None
+
+ categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
+ assert "HARM_CATEGORY_HARASSMENT" in categories
+ assert "HARM_CATEGORY_HATE_SPEECH" in categories
+ assert "HARM_CATEGORY_SEXUALLY_EXPLICIT" in categories
+ assert "HARM_CATEGORY_DANGEROUS_CONTENT" in categories
+
+ thresholds = [s.threshold.value if hasattr(s.threshold, "value") else str(s.threshold) for s in config_arg.safety_settings]
+ assert all(t == "BLOCK_NONE" for t in thresholds)
+
+
+@pytest.mark.asyncio
+async def test_call_no_safety_settings_omits_key():
+ """call() does NOT add safety_settings to GenerateContentConfig when none configured."""
+ provider = _make_gemini_provider(safety_settings=None)
+
+ fake_response = MagicMock()
+ fake_response.text = "hello"
+ fake_response.candidates = [MagicMock(finish_reason="STOP")]
+ fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
+
+ provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
+
+ await provider.call(
+ messages=[{"role": "user", "content": "hi"}],
+ scope="test",
+ )
+
+ call_args = provider._client.aio.models.generate_content.call_args
+ config_arg = call_args.kwargs.get("config")
+
+ # When no safety settings, config is either None or lacks safety_settings
+ if config_arg is not None:
+ assert not hasattr(config_arg, "safety_settings") or config_arg.safety_settings is None
+
+
+# ─── call_with_tools() applies safety settings ────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_call_with_tools_applies_safety_settings():
+ """call_with_tools() includes safety_settings in GenerateContentConfig."""
+ provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
+
+ # Build a fake tool-use response (no tool calls, just text)
+ fake_part = MagicMock()
+ fake_part.text = "answer"
+ fake_part.function_call = None
+
+ fake_candidate = MagicMock()
+ fake_candidate.content = MagicMock(parts=[fake_part])
+
+ fake_response = MagicMock()
+ fake_response.candidates = [fake_candidate]
+ fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=3)
+
+ provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
+
+ tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "test_tool",
+ "description": "A test tool",
+ "parameters": {"type": "object", "properties": {}, "required": []},
+ },
+ }
+ ]
+
+ await provider.call_with_tools(
+ messages=[{"role": "user", "content": "hi"}],
+ tools=tools,
+ scope="test",
+ )
+
+ call_args = provider._client.aio.models.generate_content.call_args
+ config_arg = call_args.kwargs.get("config")
+
+ assert config_arg is not None
+ assert config_arg.safety_settings is not None
+ categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
+ assert "HARM_CATEGORY_HARASSMENT" in categories
+
+
+# ─── Context variable override ────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_context_var_overrides_instance_settings():
+ """The context var safety settings take precedence over instance defaults."""
+ from hindsight_api.engine.providers.gemini_llm import set_gemini_safety_settings
+
+ # Instance has settings, but we'll override via context var with different settings
+ instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"}]
+ ctx_settings = [{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}]
+
+ provider = _make_gemini_provider(safety_settings=instance_settings)
+
+ fake_response = MagicMock()
+ fake_response.text = "hello"
+ fake_response.candidates = [MagicMock(finish_reason="STOP")]
+ fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
+
+ provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
+
+ # Set context var override
+ set_gemini_safety_settings(ctx_settings)
+ try:
+ await provider.call(
+ messages=[{"role": "user", "content": "hi"}],
+ scope="test",
+ )
+ finally:
+ set_gemini_safety_settings(None) # Reset context
+
+ call_args = provider._client.aio.models.generate_content.call_args
+ config_arg = call_args.kwargs.get("config")
+
+ assert config_arg is not None
+ assert config_arg.safety_settings is not None
+
+ # Should use ctx_settings (HATE_SPEECH/BLOCK_NONE), not instance_settings (HARASSMENT/BLOCK_ONLY_HIGH)
+ categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
+ assert "HARM_CATEGORY_HATE_SPEECH" in categories
+ assert "HARM_CATEGORY_HARASSMENT" not in categories
+
+
+@pytest.mark.asyncio
+async def test_context_var_none_falls_back_to_instance():
+ """When context var is None (not set), instance settings are used."""
+ from hindsight_api.engine.providers.gemini_llm import set_gemini_safety_settings
+
+ instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}]
+ provider = _make_gemini_provider(safety_settings=instance_settings)
+
+ fake_response = MagicMock()
+ fake_response.text = "hello"
+ fake_response.candidates = [MagicMock(finish_reason="STOP")]
+ fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
+
+ provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
+
+ # Explicitly set context var to None (fallback)
+ set_gemini_safety_settings(None)
+
+ await provider.call(
+ messages=[{"role": "user", "content": "hi"}],
+ scope="test",
+ )
+
+ call_args = provider._client.aio.models.generate_content.call_args
+ config_arg = call_args.kwargs.get("config")
+
+ assert config_arg is not None
+ assert config_arg.safety_settings is not None
+ categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
+ assert "HARM_CATEGORY_HARASSMENT" in categories
+
+
+# ─── LLMProvider reads safety settings from config ────────────────────────────
+
+
+def test_llm_provider_reads_safety_settings_from_config():
+ """LLMProvider reads llm_gemini_safety_settings from global config for Gemini provider."""
+ import json
+
+ from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, clear_config_cache
+
+ settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS)
+ env_overrides = {
+ "HINDSIGHT_API_LLM_PROVIDER": "gemini",
+ "HINDSIGHT_API_LLM_API_KEY": "fake-key",
+ ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json,
+ }
+
+ with patch.dict(os.environ, env_overrides, clear=False):
+ clear_config_cache()
+ with patch("google.genai.Client") as mock_client_cls:
+ mock_client_cls.return_value = MagicMock()
+ from hindsight_api.engine.llm_wrapper import LLMProvider
+
+ provider = LLMProvider(
+ provider="gemini",
+ api_key="fake-key",
+ base_url="",
+ model="gemini-2.5-flash",
+ )
+
+ assert provider.gemini_safety_settings == SAMPLE_SAFETY_SETTINGS
+
+ clear_config_cache()
diff --git a/hindsight-api/tests/test_hierarchical_config.py b/hindsight-api/tests/test_hierarchical_config.py
index e1597152..126909b2 100644
--- a/hindsight-api/tests/test_hierarchical_config.py
+++ b/hindsight-api/tests/test_hierarchical_config.py
@@ -86,7 +86,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
- assert len(configurable) == 13
+ assert len(configurable) == 14
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx
index c3c57c88..c8a8a326 100644
--- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx
+++ b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx
@@ -197,10 +197,15 @@ export default function BankPage() {
Bank configuration is disabled
+
+ Set{" "}
+
+ HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true
+ {" "}
+ to enable per-bank configuration.
+
Gemini / Vertex AI
++ Set the blocking threshold for each harm category. "Off" disables the filter entirely + (default for Gemini 2.5+). Lower thresholds block more content.{" "} + + Learn more + +
+