feat: add configurable Gemini/Vertex AI safety settings (#473)

Adds per-bank configurable safety settings for Gemini/Vertex AI:
- New `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` env var (JSON array)
- Hierarchical config field so banks can override via Config API
- ContextVar pattern for zero-signature-change per-request override
- All 6 thresholds supported: UNSPECIFIED, OFF, BLOCK_NONE, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH
- UI: Models > Gemini/Vertex AI section with per-category threshold selectors and link to Google docs
- Graceful handling when bank_config_api feature is disabled
- 12 new tests covering config parsing, GeminiLLM behaviour, and context var override
This commit is contained in:
Nicolò Boschi 2026-03-03 13:48:29 +01:00 committed by GitHub
parent 7942f181c2
commit 73ef99e7b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 637 additions and 19 deletions

View file

@ -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_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY" 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 # Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE" 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_REGION = "us-central1"
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set 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_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5" 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) 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_region: str
llm_vertexai_service_account_key: str | None 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) # Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None retain_llm_provider: str | None
retain_llm_api_key: str | None retain_llm_api_key: str | None
@ -792,6 +801,8 @@ class HindsightConfig:
"disposition_skepticism", "disposition_skepticism",
"disposition_literalism", "disposition_literalism",
"disposition_empathy", "disposition_empathy",
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
"llm_gemini_safety_settings",
} }
@property @property
@ -912,6 +923,8 @@ class HindsightConfig:
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION), 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) llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
or DEFAULT_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) # Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None, retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None, retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,

View file

@ -126,6 +126,12 @@ async def run_consolidation_job(
""" """
# Resolve bank-specific config with hierarchical overrides # Resolve bank-specific config with hierarchical overrides
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context) 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) perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size max_memories_per_batch = config.consolidation_batch_size
llm_batch_size = max(1, config.consolidation_llm_batch_size) llm_batch_size = max(1, config.consolidation_llm_batch_size)

View file

@ -124,6 +124,7 @@ def create_llm_provider(
vertexai_project_id: str | None = None, vertexai_project_id: str | None = None,
vertexai_region: str | None = None, vertexai_region: str | None = None,
vertexai_credentials: Any = None, vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
) -> Any: # Returns LLMInterface ) -> Any: # Returns LLMInterface
""" """
Factory function to create the appropriate LLM provider implementation. Factory function to create the appropriate LLM provider implementation.
@ -192,6 +193,7 @@ def create_llm_provider(
vertexai_project_id=vertexai_project_id, vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region, vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials, vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
) )
elif provider_lower == "anthropic": elif provider_lower == "anthropic":
@ -234,6 +236,7 @@ class LLMProvider:
reasoning_effort: str = "low", reasoning_effort: str = "low",
groq_service_tier: str | None = None, groq_service_tier: str | None = None,
openai_service_tier: str | None = None, openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
): ):
""" """
Initialize LLM provider. Initialize LLM provider.
@ -246,6 +249,7 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers. reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config. groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - 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.provider = provider.lower()
self.api_key = api_key self.api_key = api_key
@ -255,6 +259,8 @@ class LLMProvider:
# Service tiers from hierarchical config (not env vars) # Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_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 # Validate provider
valid_providers = [ valid_providers = [
@ -323,6 +329,18 @@ class LLMProvider:
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}" 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 # Create provider implementation using factory
self._provider_impl = create_llm_provider( self._provider_impl = create_llm_provider(
provider=self.provider, provider=self.provider,
@ -335,6 +353,7 @@ class LLMProvider:
vertexai_project_id=vertexai_project_id, vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region, vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials, vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
) )
# Backward compatibility: Keep mock provider properties # Backward compatibility: Keep mock provider properties

View file

@ -1831,6 +1831,11 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation # Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context) 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 # Create parent span for retain operation
with create_operation_span("retain", bank_id): with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch( return await orchestrator.retain_batch(
@ -4482,6 +4487,12 @@ class MemoryEngine(MemoryEngineInterface):
# The agent can call lookup() to list available models if needed. # The agent can call lookup() to list available models if needed.
# This is critical for banks with many mental models to avoid huge prompts. # 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 # Compute max iterations based on budget
config = get_config() config = get_config()
base_max_iterations = config.reflect_max_iterations base_max_iterations = config.reflect_max_iterations

View file

@ -11,6 +11,7 @@ import json
import logging import logging
import os import os
import time import time
from contextvars import ContextVar
from typing import Any from typing import Any
from google import genai from google import genai
@ -24,6 +25,25 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__) 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) # Vertex AI imports (optional)
try: try:
import google.auth import google.auth
@ -58,6 +78,9 @@ class GeminiLLM(LLMInterface):
self._client = None self._client = None
self._is_vertexai = self.provider == "vertexai" 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: if self._is_vertexai:
self._init_vertexai(**kwargs) self._init_vertexai(**kwargs)
else: else:
@ -216,6 +239,16 @@ class GeminiLLM(LLMInterface):
if temperature is not None: if temperature is not None:
config_kwargs["temperature"] = temperature 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 generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None last_exception = None
@ -489,6 +522,16 @@ class GeminiLLM(LLMInterface):
) )
# "auto" is the default (no tool_config needed) # "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) config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None last_exception = None

View file

@ -171,6 +171,7 @@ def main():
llm_vertexai_project_id=config.llm_vertexai_project_id, llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region, llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key, 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_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key, retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model, retain_llm_model=config.retain_llm_model,

View file

@ -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()

View file

@ -86,7 +86,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable assert "entity_labels" in configurable
# Verify count is correct # Verify count is correct
assert len(configurable) == 13 assert len(configurable) == 14
# Verify credential fields (NEVER exposed) # Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials assert "llm_api_key" in credentials

View file

@ -197,10 +197,15 @@ export default function BankPage() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => setShowResetConfigDialog(true)} onClick={() => setShowResetConfigDialog(true)}
disabled={!bankConfigEnabled}
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300" className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
title={!bankConfigEnabled ? "Bank Config API is disabled" : undefined}
> >
<RotateCcw className="w-4 h-4 mr-2" /> <RotateCcw className="w-4 h-4 mr-2" />
Reset Configuration Reset Configuration
{!bankConfigEnabled && (
<span className="ml-auto text-xs text-muted-foreground">Off</span>
)}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@ -230,19 +235,21 @@ export default function BankPage() {
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" /> <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)} )}
</button> </button>
<button {bankConfigEnabled && (
onClick={() => handleBankConfigTabChange("configuration")} <button
className={`px-6 py-3 font-semibold text-sm transition-all relative ${ onClick={() => handleBankConfigTabChange("configuration")}
bankConfigTab === "configuration" className={`px-6 py-3 font-semibold text-sm transition-all relative ${
? "text-primary" bankConfigTab === "configuration"
: "text-muted-foreground hover:text-foreground" ? "text-primary"
}`} : "text-muted-foreground hover:text-foreground"
> }`}
Configuration >
{bankConfigTab === "configuration" && ( Configuration
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" /> {bankConfigTab === "configuration" && (
)} <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
</button> )}
</button>
)}
</div> </div>
</div> </div>
@ -260,7 +267,7 @@ export default function BankPage() {
</div> </div>
</div> </div>
)} )}
{bankConfigTab === "configuration" && ( {bankConfigTab === "configuration" && bankConfigEnabled && (
<div className="space-y-6"> <div className="space-y-6">
<BankConfigView /> <BankConfigView />
</div> </div>

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, type ReactNode } from "react"; import { useState, useEffect, useMemo, type ReactNode } from "react";
import { useBank } from "@/lib/bank-context"; import { useBank } from "@/lib/bank-context";
import { useFeatures } from "@/lib/features-context";
import { client } from "@/lib/api"; import { client } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -60,6 +61,38 @@ type MCPEdits = {
mcp_enabled_tools: string[] | null; mcp_enabled_tools: string[] | null;
}; };
type GeminiSafetySetting = {
category: string;
threshold: string;
};
type GeminiEdits = {
llm_gemini_safety_settings: GeminiSafetySetting[] | null;
};
// ─── Gemini safety settings catalogue ────────────────────────────────────────
const GEMINI_HARM_CATEGORIES = [
{ value: "HARM_CATEGORY_HARASSMENT", label: "Harassment" },
{ value: "HARM_CATEGORY_HATE_SPEECH", label: "Hate Speech" },
{ value: "HARM_CATEGORY_SEXUALLY_EXPLICIT", label: "Sexually Explicit" },
{ value: "HARM_CATEGORY_DANGEROUS_CONTENT", label: "Dangerous Content" },
] as const;
const GEMINI_THRESHOLDS = [
{ value: "HARM_BLOCK_THRESHOLD_UNSPECIFIED", label: "Unspecified (use Gemini default)" },
{ value: "OFF", label: "Off (filter disabled)" },
{ value: "BLOCK_NONE", label: "Block none" },
{ value: "BLOCK_LOW_AND_ABOVE", label: "Block low & above" },
{ value: "BLOCK_MEDIUM_AND_ABOVE", label: "Block medium & above" },
{ value: "BLOCK_ONLY_HIGH", label: "Block only high" },
] as const;
const DEFAULT_GEMINI_SAFETY_SETTINGS: GeminiSafetySetting[] = GEMINI_HARM_CATEGORIES.map((c) => ({
category: c.value,
threshold: "BLOCK_NONE",
}));
// ─── MCP tool catalogue ─────────────────────────────────────────────────────── // ─── MCP tool catalogue ───────────────────────────────────────────────────────
const MCP_TOOL_GROUPS: { label: string; tools: string[] }[] = [ const MCP_TOOL_GROUPS: { label: string; tools: string[] }[] = [
@ -134,6 +167,12 @@ function mcpSlice(config: Record<string, any>): MCPEdits {
}; };
} }
function geminiSlice(config: Record<string, any>): GeminiEdits {
return {
llm_gemini_safety_settings: config.llm_gemini_safety_settings ?? null,
};
}
const DEFAULT_PROFILE: ProfileData = { const DEFAULT_PROFILE: ProfileData = {
reflect_mission: "", reflect_mission: "",
disposition_skepticism: 3, disposition_skepticism: 3,
@ -145,6 +184,8 @@ const DEFAULT_PROFILE: ProfileData = {
export function BankConfigView() { export function BankConfigView() {
const { currentBank: bankId } = useBank(); const { currentBank: bankId } = useBank();
const { features } = useFeatures();
const bankConfigEnabled = features?.bank_config_api ?? true; // optimistic default while loading
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Source of truth // Source of truth
@ -161,6 +202,7 @@ export function BankConfigView() {
); );
const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE); const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE);
const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({})); const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({}));
const [geminiEdits, setGeminiEdits] = useState<GeminiEdits>(geminiSlice({}));
// Per-section saving/error state // Per-section saving/error state
const [retainSaving, setRetainSaving] = useState(false); const [retainSaving, setRetainSaving] = useState(false);
@ -168,11 +210,13 @@ export function BankConfigView() {
const [entityLabelsSaving, setEntityLabelsSaving] = useState(false); const [entityLabelsSaving, setEntityLabelsSaving] = useState(false);
const [reflectSaving, setReflectSaving] = useState(false); const [reflectSaving, setReflectSaving] = useState(false);
const [mcpSaving, setMcpSaving] = useState(false); const [mcpSaving, setMcpSaving] = useState(false);
const [geminiSaving, setGeminiSaving] = useState(false);
const [retainError, setRetainError] = useState<string | null>(null); const [retainError, setRetainError] = useState<string | null>(null);
const [observationsError, setObservationsError] = useState<string | null>(null); const [observationsError, setObservationsError] = useState<string | null>(null);
const [entityLabelsError, setEntityLabelsError] = useState<string | null>(null); const [entityLabelsError, setEntityLabelsError] = useState<string | null>(null);
const [reflectError, setReflectError] = useState<string | null>(null); const [reflectError, setReflectError] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null); const [mcpError, setMcpError] = useState<string | null>(null);
const [geminiError, setGeminiError] = useState<string | null>(null);
// Reset dialog // Reset dialog
@ -197,6 +241,10 @@ export function BankConfigView() {
() => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)), () => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)),
[mcpEdits, baseConfig] [mcpEdits, baseConfig]
); );
const geminiDirty = useMemo(
() => JSON.stringify(geminiEdits) !== JSON.stringify(geminiSlice(baseConfig)),
[geminiEdits, baseConfig]
);
useEffect(() => { useEffect(() => {
if (bankId) loadAll(); if (bankId) loadAll();
@ -226,6 +274,7 @@ export function BankConfigView() {
setEntityLabelsEdits(entityLabelsSlice(cfg)); setEntityLabelsEdits(entityLabelsSlice(cfg));
setReflectEdits(prof); setReflectEdits(prof);
setMcpEdits(mcpSlice(cfg)); setMcpEdits(mcpSlice(cfg));
setGeminiEdits(geminiSlice(cfg));
} catch (err) { } catch (err) {
console.error("Failed to load bank data:", err); console.error("Failed to load bank data:", err);
} finally { } finally {
@ -312,6 +361,20 @@ export function BankConfigView() {
} }
}; };
const saveGemini = async () => {
if (!bankId) return;
setGeminiSaving(true);
setGeminiError(null);
try {
await client.updateBankConfig(bankId, geminiEdits);
setBaseConfig((prev) => ({ ...prev, ...geminiEdits }));
} catch (err: any) {
setGeminiError(err.message || "Failed to save Gemini settings");
} finally {
setGeminiSaving(false);
}
};
if (!bankId) { if (!bankId) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@ -320,6 +383,21 @@ export function BankConfigView() {
); );
} }
if (!bankConfigEnabled) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<p className="text-base font-medium text-foreground">Bank configuration is disabled</p>
<p className="text-sm text-muted-foreground max-w-sm">
Set{" "}
<code className="font-mono text-xs bg-muted px-1 py-0.5 rounded">
HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true
</code>{" "}
to enable per-bank configuration.
</p>
</div>
);
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@ -551,6 +629,62 @@ export function BankConfigView() {
/> />
)} )}
</ConfigSection> </ConfigSection>
{/* Models Section */}
<ConfigSection
title="Models"
description="Provider-specific model settings"
error={geminiError}
dirty={geminiDirty}
saving={geminiSaving}
onSave={saveGemini}
>
{/* Gemini subsection */}
<div className="px-6 py-4 space-y-4">
<p className="text-sm font-semibold">Gemini / Vertex AI</p>
<div className="pl-4 border-l-2 border-border/40 space-y-4">
<FieldRow
label="Safety settings"
description={
<>
When off, Gemini&apos;s default safety thresholds are used. When on, configure
thresholds per harm category.{" "}
<a
href="https://ai.google.dev/gemini-api/docs/safety-settings"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
Learn more
</a>
</>
}
>
<div className="flex items-center gap-2 justify-end">
<Switch
checked={geminiEdits.llm_gemini_safety_settings !== null}
onCheckedChange={(enabled) =>
setGeminiEdits({
llm_gemini_safety_settings: enabled
? [...DEFAULT_GEMINI_SAFETY_SETTINGS]
: null,
})
}
/>
<Label className="text-xs text-muted-foreground">
{geminiEdits.llm_gemini_safety_settings !== null ? "Custom" : "Default"}
</Label>
</div>
</FieldRow>
{geminiEdits.llm_gemini_safety_settings !== null && (
<GeminiSafetyEditor
value={geminiEdits.llm_gemini_safety_settings}
onChange={(settings) => setGeminiEdits({ llm_gemini_safety_settings: settings })}
/>
)}
</div>
</div>
</ConfigSection>
</div> </div>
</> </>
); );
@ -712,7 +846,7 @@ function FieldRow({
children, children,
}: { }: {
label: string; label: string;
description?: string; description?: ReactNode;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
@ -1029,3 +1163,64 @@ function EntityLabelsEditor({
</div> </div>
); );
} }
// ─── GeminiSafetyEditor ───────────────────────────────────────────────────────
function GeminiSafetyEditor({
value,
onChange,
}: {
value: GeminiSafetySetting[];
onChange: (settings: GeminiSafetySetting[]) => void;
}) {
const getThreshold = (category: string): string => {
return value.find((s) => s.category === category)?.threshold ?? "BLOCK_MEDIUM_AND_ABOVE";
};
const setThreshold = (category: string, threshold: string) => {
const next = GEMINI_HARM_CATEGORIES.map((c) => ({
category: c.value,
threshold: c.value === category ? threshold : getThreshold(c.value),
}));
onChange(next);
};
return (
<div className="px-6 py-4 space-y-3">
<p className="text-xs text-muted-foreground">
Set the blocking threshold for each harm category. "Off" disables the filter entirely
(default for Gemini 2.5+). Lower thresholds block more content.{" "}
<a
href="https://ai.google.dev/gemini-api/docs/safety-settings"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
Learn more
</a>
</p>
<div className="space-y-2">
{GEMINI_HARM_CATEGORIES.map((cat) => (
<div key={cat.value} className="flex items-center justify-between gap-4">
<span className="text-sm">{cat.label}</span>
<Select
value={getThreshold(cat.value)}
onValueChange={(v) => setThreshold(cat.value, v)}
>
<SelectTrigger className="w-48 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GEMINI_THRESHOLDS.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
);
}

View file

@ -86,8 +86,6 @@ console.log(`Page items: ${page.items.length}`);
// [docs:document-get] // [docs:document-get]
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// Get document to expand context from recall results // Get document to expand context from recall results
const { data: doc, error } = await sdk.getDocument({ const { data: doc, error } = await sdk.getDocument({
client: apiClient, client: apiClient,

View file

@ -91,12 +91,12 @@ async def list_documents_example():
result = await api.list_documents(bank_id="my-bank", limit=20, offset=40) result = await api.list_documents(bank_id="my-bank", limit=20, offset=40)
print(f"Page items: {len(result.items)}") print(f"Page items: {len(result.items)}")
import asyncio
asyncio.run(list_documents_example()) asyncio.run(list_documents_example())
# [/docs:document-list] # [/docs:document-list]
# [docs:document-get] # [docs:document-get]
import asyncio
from hindsight_client_api import ApiClient, Configuration from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi from hindsight_client_api.api import DocumentsApi